Security audit follow-up: httpOnly cookies, ticket reservations, MongoDB keyfile

Deferred findings from the March 27 security audit, plus a bug fix:

MongoDB keyfile (bug fix):
- Generate replica.key on first boot via entrypoint script
- Fixes crash from --auth + --keyFile without an existing keyfile
- Applied to docker-compose.yml, docker-compose.prod.yml, CCP template

I7 — Ticket overselling prevention (reservation pattern):
- Add reservedCount field to TicketTier schema
- Atomically increment reservedCount inside transaction on checkout
- Release reservation on checkout.session.completed (webhook)
- Release reservation on checkout.session.expired (webhook)
- Include reservedCount in availability calculations

I17 — Move refresh token to httpOnly cookie:
- Server sets httpOnly SameSite=Strict cookie on login/register/refresh
- Cookie scoped to /api/auth path, secure in production
- Refresh/logout endpoints read from cookie (with body fallback for compat)
- Frontend no longer stores refreshToken in localStorage
- Auth store simplified: removed refreshToken from state + persistence
- API interceptor uses withCredentials:true for automatic cookie sending
- Updated media-api, media-public-api, QuickJoinPage, volunteer-invite
- Renamed getTokens → getAccessToken across all media components
- Install cookie-parser middleware

L2 — FeatureGate loading state:
- Show Skeleton instead of children while settings are loading
- Prevents briefly exposing disabled feature pages

Bunker Admin
This commit is contained in:
bunker-admin 2026-03-27 09:20:26 -06:00
parent 82a66a97d0
commit b215cda018
25 changed files with 201 additions and 105 deletions

View File

@ -1,5 +1,5 @@
import type { ReactNode } from 'react';
import { Result, Button } from 'antd';
import { Result, Button, Skeleton } from 'antd';
import { useNavigate } from 'react-router-dom';
import { SettingOutlined } from '@ant-design/icons';
import { useSettingsStore } from '@/stores/settings.store';
@ -36,8 +36,8 @@ export default function FeatureGate({ feature, children }: FeatureGateProps) {
const isSuperAdmin = hasAnyRole(user, ['SUPER_ADMIN']);
const featureName = FEATURE_LABELS[feature] || feature;
// While loading or if settings haven't arrived yet, render children (optimistic)
if (loading || !settings) return <>{children}</>;
// Show skeleton while settings are loading to prevent briefly showing disabled features
if (loading || !settings) return <Skeleton active style={{ padding: 24 }} />;
if (settings[feature] === false) {
return (

View File

@ -5,8 +5,8 @@ import type { PhotoAlbum } from '@/types/media';
/** Append JWT access token as query param for <img> src URLs */
function getAuthenticatedUrl(url: string): string {
const { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
const { getAccessToken } = getAuthCallbacks();
const accessToken = getAccessToken();
if (!accessToken) return url;
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${accessToken}`;

View File

@ -12,8 +12,8 @@ import type { PhotoAlbum, PhotoAlbumItem } from '@/types/media';
/** Append JWT access token as query param for <img> src URLs */
function getAuthenticatedUrl(url: string): string {
const { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
const { getAccessToken } = getAuthCallbacks();
const accessToken = getAccessToken();
if (!accessToken) return url;
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${accessToken}`;

View File

@ -13,8 +13,8 @@ import type { Photo } from '@/types/media';
/** Append JWT access token as query param for <img> src URLs */
function getAuthenticatedUrl(url: string): string {
const { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
const { getAccessToken } = getAuthCallbacks();
const accessToken = getAccessToken();
if (!accessToken) return url;
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${accessToken}`;

View File

@ -5,8 +5,8 @@ import type { Photo } from '@/types/media';
/** Append JWT access token as query param for <img> src URLs */
function getAuthenticatedUrl(url: string): string {
const { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
const { getAccessToken } = getAuthCallbacks();
const accessToken = getAccessToken();
if (!accessToken) return url;
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${accessToken}`;

View File

@ -8,8 +8,8 @@ import ScheduleBadge from './ScheduleBadge';
/** Append JWT access token as query param for <img>/<video> src URLs */
function getAuthenticatedUrl(url: string): string {
const { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
const { getAccessToken } = getAuthCallbacks();
const accessToken = getAccessToken();
if (!accessToken) return url;
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${accessToken}`;

View File

@ -124,8 +124,8 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
const appendToken = (url: string): string => {
if (!isAdmin) return url;
const { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
const { getAccessToken } = getAuthCallbacks();
const accessToken = getAccessToken();
if (!accessToken) return url;
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}token=${accessToken}`;
@ -139,8 +139,8 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
// Use relative URL to go through nginx proxy
const headers: Record<string, string> = {};
if (isAdmin) {
const { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
const { getAccessToken } = getAuthCallbacks();
const accessToken = getAccessToken();
if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
}

View File

@ -6,8 +6,8 @@ import { getAuthCallbacks } from '@/lib/api';
/** Append JWT access token as query param for <video> src URLs */
function getAuthenticatedUrl(url: string): string {
const { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
const { getAccessToken } = getAuthCallbacks();
const accessToken = getAccessToken();
if (!accessToken) return url;
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${accessToken}`;

View File

@ -3,32 +3,32 @@ import type { AuthResponse } from '@/types/api';
export const api = axios.create({
baseURL: '/api',
withCredentials: true, // Send httpOnly cookies with all requests
});
// Token accessor — set by auth store on init to break circular dependency
let getTokens: () => { accessToken: string | null; refreshToken: string | null } =
() => ({ accessToken: null, refreshToken: null });
let onTokenRefresh: (accessToken: string, refreshToken: string) => void = () => {};
let getAccessToken: () => string | null = () => null;
let onTokenRefresh: (accessToken: string) => void = () => {};
let onAuthFailure: () => void = () => {};
export function registerAuthCallbacks(callbacks: {
getTokens: typeof getTokens;
getAccessToken: typeof getAccessToken;
onTokenRefresh: typeof onTokenRefresh;
onAuthFailure: typeof onAuthFailure;
}) {
getTokens = callbacks.getTokens;
getAccessToken = callbacks.getAccessToken;
onTokenRefresh = callbacks.onTokenRefresh;
onAuthFailure = callbacks.onAuthFailure;
}
// Helper to get current callbacks (for use in other API clients)
export function getAuthCallbacks() {
return { getTokens, onTokenRefresh, onAuthFailure };
return { getAccessToken, onTokenRefresh, onAuthFailure };
}
// Request interceptor: attach access token
api.interceptors.request.use((config) => {
const { accessToken } = getTokens();
const accessToken = getAccessToken();
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
@ -51,16 +51,11 @@ api.interceptors.response.use(
) {
originalRequest._retry = true;
const { refreshToken } = getTokens();
if (!refreshToken) {
onAuthFailure();
return Promise.reject(error);
}
try {
if (!refreshPromise) {
// Refresh token sent automatically as httpOnly cookie
refreshPromise = api
.post<AuthResponse>('/auth/refresh', { refreshToken })
.post<AuthResponse>('/auth/refresh')
.then((res) => res.data)
.finally(() => {
refreshPromise = null;
@ -68,7 +63,7 @@ api.interceptors.response.use(
}
const data = await refreshPromise;
onTokenRefresh(data.accessToken!, data.refreshToken!);
onTokenRefresh(data.accessToken!);
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
return api(originalRequest);

View File

@ -8,13 +8,14 @@ export const mediaApi = axios.create({
headers: {
'Content-Type': 'application/json',
},
withCredentials: true, // Send httpOnly cookies with requests
});
// Request interceptor: attach Bearer token from auth store
mediaApi.interceptors.request.use(
(config) => {
const callbacks = getAuthCallbacks();
const { accessToken } = callbacks.getTokens();
const accessToken = callbacks.getAccessToken();
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
@ -41,22 +42,15 @@ mediaApi.interceptors.response.use(
originalRequest._retry = true;
const callbacks = getAuthCallbacks();
const { refreshToken } = callbacks.getTokens();
// No refresh token available - fail immediately
if (!refreshToken) {
callbacks.onAuthFailure();
return Promise.reject(error);
}
try {
// Use shared refresh promise to prevent concurrent refreshes
if (!mediaRefreshPromise) {
// Import main API client dynamically to avoid circular dependency
// Refresh token sent automatically as httpOnly cookie
const { api } = await import('./api');
mediaRefreshPromise = api
.post<AuthResponse>('/auth/refresh', { refreshToken })
.post<AuthResponse>('/auth/refresh')
.then((res) => res.data)
.finally(() => {
mediaRefreshPromise = null;
@ -65,8 +59,8 @@ mediaApi.interceptors.response.use(
const data = await mediaRefreshPromise;
// Update tokens in auth store via callback
callbacks.onTokenRefresh(data.accessToken!, data.refreshToken!);
// Update access token in auth store via callback
callbacks.onTokenRefresh(data.accessToken!);
// Retry original request with new token
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;

View File

@ -41,7 +41,7 @@ mediaPublicApi.interceptors.request.use(
// Optionally attach Bearer token if user is logged in
const callbacks = getAuthCallbacks();
const { accessToken } = callbacks.getTokens();
const accessToken = callbacks.getAccessToken();
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}

View File

@ -13,7 +13,7 @@ const { Title, Text } = Typography;
export default function QuickJoinPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { setTokens, fetchMe } = useAuthStore();
const { setAccessToken, fetchMe } = useAuthStore();
const { settings } = useSettingsStore();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
@ -35,8 +35,8 @@ export default function QuickJoinPage() {
phone: values.phone || undefined,
});
// Store tokens and fetch user profile
setTokens(data.accessToken, data.refreshToken);
// Store access token and fetch user profile (refresh token set as httpOnly cookie by server)
setAccessToken(data.accessToken);
await fetchMe();
// Redirect to volunteer map with cut/shift context

View File

@ -6,7 +6,6 @@ import type { User, AuthResponse } from '@/types/api';
interface AuthState {
user: User | null;
accessToken: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
@ -21,7 +20,7 @@ interface AuthActions {
refresh: () => Promise<void>;
fetchMe: () => Promise<void>;
hydrate: () => Promise<void>;
setTokens: (accessToken: string, refreshToken: string) => void;
setAccessToken: (accessToken: string) => void;
clearAuth: () => void;
clearError: () => void;
}
@ -31,7 +30,6 @@ export const useAuthStore = create<AuthState & AuthActions>()(
(set, get) => ({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
isLoading: true,
error: null,
@ -41,6 +39,7 @@ export const useAuthStore = create<AuthState & AuthActions>()(
login: async (email: string, password: string) => {
set({ error: null, errorCode: null, isLoading: true, registrationMessage: null });
try {
// Refresh token is set as httpOnly cookie by the server (not in response body)
const { data } = await api.post<AuthResponse>('/auth/login', {
email,
password,
@ -48,7 +47,6 @@ export const useAuthStore = create<AuthState & AuthActions>()(
set({
user: data.user,
accessToken: data.accessToken || null,
refreshToken: data.refreshToken || null,
isAuthenticated: true,
isLoading: false,
});
@ -83,7 +81,6 @@ export const useAuthStore = create<AuthState & AuthActions>()(
set({
user: data.user,
accessToken: data.accessToken || null,
refreshToken: data.refreshToken || null,
isAuthenticated: true,
isLoading: false,
});
@ -98,11 +95,9 @@ export const useAuthStore = create<AuthState & AuthActions>()(
},
logout: async () => {
const { refreshToken } = get();
try {
if (refreshToken) {
await api.post('/auth/logout', { refreshToken });
}
// Refresh token sent automatically as httpOnly cookie
await api.post('/auth/logout');
} catch {
// Ignore logout errors
}
@ -110,19 +105,12 @@ export const useAuthStore = create<AuthState & AuthActions>()(
},
refresh: async () => {
const { refreshToken } = get();
if (!refreshToken) {
get().clearAuth();
return;
}
try {
const { data } = await api.post<AuthResponse>('/auth/refresh', {
refreshToken,
});
// Refresh token sent automatically as httpOnly cookie
const { data } = await api.post<AuthResponse>('/auth/refresh');
set({
user: data.user,
accessToken: data.accessToken || null,
refreshToken: data.refreshToken || null,
isAuthenticated: true,
});
} catch {
@ -157,15 +145,14 @@ export const useAuthStore = create<AuthState & AuthActions>()(
}
},
setTokens: (accessToken: string, refreshToken: string) => {
set({ accessToken, refreshToken });
setAccessToken: (accessToken: string) => {
set({ accessToken });
},
clearAuth: () => {
set({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
isLoading: false,
error: null,
@ -182,7 +169,6 @@ export const useAuthStore = create<AuthState & AuthActions>()(
name: 'cml-auth',
partialize: (state) => ({
accessToken: state.accessToken,
refreshToken: state.refreshToken,
}),
}
)
@ -190,15 +176,9 @@ export const useAuthStore = create<AuthState & AuthActions>()(
// Register callbacks for the API interceptor (breaks circular dependency)
registerAuthCallbacks({
getTokens: () => {
const state = useAuthStore.getState();
return {
accessToken: state.accessToken,
refreshToken: state.refreshToken,
};
},
onTokenRefresh: (accessToken, refreshToken) => {
useAuthStore.getState().setTokens(accessToken, refreshToken);
getAccessToken: () => useAuthStore.getState().accessToken,
onTokenRefresh: (accessToken) => {
useAuthStore.getState().setAccessToken(accessToken);
},
onAuthFailure: () => {
useAuthStore.getState().clearAuth();

28
api/package-lock.json generated
View File

@ -17,6 +17,7 @@
"bcryptjs": "^2.4.3",
"bullmq": "^5.34.0",
"compression": "^1.7.5",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"csv-parse": "^6.1.0",
"csv-stringify": "^6.6.0",
@ -52,6 +53,7 @@
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/compression": "^1.7.5",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",
@ -1894,6 +1896,15 @@
"@types/node": "*"
}
},
"node_modules/@types/cookie-parser": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
"dev": true,
"peerDependencies": {
"@types/express": "*"
}
},
"node_modules/@types/cors": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
@ -2533,6 +2544,23 @@
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",

View File

@ -25,6 +25,7 @@
"bcryptjs": "^2.4.3",
"bullmq": "^5.34.0",
"compression": "^1.7.5",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"csv-parse": "^6.1.0",
"csv-stringify": "^6.6.0",
@ -60,6 +61,7 @@
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/compression": "^1.7.5",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",

View File

@ -4830,6 +4830,7 @@ model TicketTier {
minDonationCAD Int? @map("min_donation_cad") // In cents
maxQuantity Int? @map("max_quantity")
soldCount Int @default(0) @map("sold_count")
reservedCount Int @default(0) @map("reserved_count") // Pending checkout sessions
maxPerOrder Int @default(10) @map("max_per_order")
salesStartAt DateTime? @map("sales_start_at")
salesEndAt DateTime? @map("sales_end_at")

View File

@ -19,6 +19,30 @@ import { profileService } from '../people/profile.service';
const router = Router();
const REFRESH_COOKIE_NAME = 'cml_refresh';
const REFRESH_COOKIE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days in ms
/** Set the refresh token as an httpOnly cookie */
function setRefreshCookie(res: Response, token: string) {
res.cookie(REFRESH_COOKIE_NAME, token, {
httpOnly: true,
secure: env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: REFRESH_COOKIE_MAX_AGE,
path: '/api/auth',
});
}
/** Clear the refresh token cookie */
function clearRefreshCookie(res: Response) {
res.clearCookie(REFRESH_COOKIE_NAME, {
httpOnly: true,
secure: env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/api/auth',
});
}
// POST /api/auth/login
router.post(
'/login',
@ -27,7 +51,10 @@ router.post(
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await authService.login(req.body.email, req.body.password);
res.json(result);
// Set refresh token as httpOnly cookie (not in response body)
setRefreshCookie(res, result.refreshToken);
const { refreshToken: _, ...responseWithoutRefresh } = result;
res.json(responseWithoutRefresh);
} catch (err) {
next(err);
}
@ -42,7 +69,14 @@ router.post(
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await authService.register(req.body);
res.status(201).json(result);
// Set refresh token as httpOnly cookie if tokens were issued (non-verification path)
if ('refreshToken' in result && result.refreshToken) {
setRefreshCookie(res, result.refreshToken);
const { refreshToken: _, ...responseWithoutRefresh } = result;
res.status(201).json(responseWithoutRefresh);
} else {
res.status(201).json(result);
}
} catch (err) {
next(err);
}
@ -231,30 +265,44 @@ router.post(
);
// POST /api/auth/refresh
// Accepts refresh token from httpOnly cookie (preferred) or request body (legacy/backward compat)
router.post(
'/refresh',
authRateLimit,
validate(refreshSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await authService.refreshTokens(req.body.refreshToken);
res.json(result);
const refreshToken = req.cookies?.[REFRESH_COOKIE_NAME] || req.body?.refreshToken;
if (!refreshToken) {
res.status(401).json({ error: { message: 'No refresh token', code: 'INVALID_REFRESH_TOKEN' } });
return;
}
const result = await authService.refreshTokens(refreshToken);
// Set new refresh token as httpOnly cookie
setRefreshCookie(res, result.refreshToken);
const { refreshToken: _, ...responseWithoutRefresh } = result;
res.json(responseWithoutRefresh);
} catch (err) {
clearRefreshCookie(res);
next(err);
}
}
);
// POST /api/auth/logout
// Accepts refresh token from httpOnly cookie (preferred) or request body (legacy/backward compat)
router.post(
'/logout',
authRateLimit,
validate(refreshSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
await authService.logout(req.body.refreshToken);
const refreshToken = req.cookies?.[REFRESH_COOKIE_NAME] || req.body?.refreshToken;
if (refreshToken) {
await authService.logout(refreshToken);
}
clearRefreshCookie(res);
res.json({ message: 'Logged out' });
} catch (err) {
clearRefreshCookie(res);
next(err);
}
}

View File

@ -424,16 +424,23 @@ export const webhookService = {
const { ticketsService } = await import('../ticketed-events/tickets.service');
const { ticketEmailService } = await import('../ticketed-events/ticket-email.service');
const qty = parseInt(quantity, 10);
const tickets = await ticketsService.createTickets({
eventId,
tierId,
quantity: parseInt(quantity, 10),
quantity: qty,
holderEmail: buyerEmail || order.buyerEmail,
holderName: buyerName || order.buyerName || undefined,
userId: userId || order.userId || undefined,
orderId: order.id,
});
// Release reservation (soldCount was incremented by createTickets)
await prisma.ticketTier.update({
where: { id: tierId },
data: { reservedCount: { decrement: qty } },
});
// Fetch event + tier for email
const event = await prisma.ticketedEvent.findUnique({ where: { id: eventId } });
const tier = await prisma.ticketTier.findUnique({ where: { id: tierId } });
@ -534,6 +541,21 @@ export const webhookService = {
where: { id: order.id },
data: { status: 'FAILED' },
});
// Release ticket reservations if this was an event ticket checkout
const { tierId, quantity } = session.metadata || {};
if (tierId && quantity) {
const qty = parseInt(quantity, 10);
if (qty > 0) {
await prisma.ticketTier.update({
where: { id: tierId },
data: { reservedCount: { decrement: qty } },
}).catch((err) => {
logger.warn(`Failed to release reservation for tier ${tierId}:`, err);
});
}
}
logger.info(`Checkout expired, order marked failed: ${order.id}`);
}
},

View File

@ -119,14 +119,6 @@ router.post('/:slug/checkout', optionalAuth, validate(checkoutSchema), async (re
throw new AppError(400, 'Use /register for free tickets', 'USE_REGISTER');
}
// Check availability
if (tier.maxQuantity && tier.soldCount + quantity > tier.maxQuantity) {
throw new AppError(400, 'Not enough tickets available', 'SOLD_OUT');
}
if (event.maxAttendees && event.currentAttendees + quantity > event.maxAttendees) {
throw new AppError(400, 'Event is at full capacity', 'SOLD_OUT');
}
// Check sales window
const now = new Date();
if (tier.salesStartAt && tier.salesStartAt > now) {
@ -136,9 +128,33 @@ router.post('/:slug/checkout', optionalAuth, validate(checkoutSchema), async (re
throw new AppError(400, 'Ticket sales have ended', 'SALES_ENDED');
}
const unitAmount = tier.tierType === 'DONATION'
? Math.max(tier.priceCAD, tier.minDonationCAD || 0)
: tier.priceCAD;
// Atomically reserve capacity (soldCount + reservedCount checked together)
// This prevents overselling from concurrent Stripe checkout sessions
const reserved = await prisma.$transaction(async (tx) => {
const currentTier = await tx.ticketTier.findUnique({ where: { id: tierId } });
if (!currentTier) throw new AppError(400, 'Tier not found', 'NOT_FOUND');
const effectiveSold = currentTier.soldCount + currentTier.reservedCount;
if (currentTier.maxQuantity && effectiveSold + quantity > currentTier.maxQuantity) {
throw new AppError(400, 'Not enough tickets available', 'SOLD_OUT');
}
const currentEvent = await tx.ticketedEvent.findUnique({ where: { id: event.id } });
if (currentEvent?.maxAttendees && currentEvent.currentAttendees + currentTier.reservedCount + quantity > currentEvent.maxAttendees) {
throw new AppError(400, 'Event is at full capacity', 'SOLD_OUT');
}
await tx.ticketTier.update({
where: { id: tierId },
data: { reservedCount: { increment: quantity } },
});
return currentTier;
});
const unitAmount = reserved.tierType === 'DONATION'
? Math.max(reserved.priceCAD, reserved.minDonationCAD || 0)
: reserved.priceCAD;
const stripe = await getStripe();
const session = await stripe.checkout.sessions.create({

View File

@ -696,7 +696,7 @@ export const ticketedEventsService = {
const now = new Date();
const tiers = event.ticketTiers.map(t => {
const available = t.maxQuantity ? t.maxQuantity - t.soldCount : null;
const available = t.maxQuantity ? t.maxQuantity - t.soldCount - (t.reservedCount || 0) : null;
const onSale = (!t.salesStartAt || t.salesStartAt <= now) &&
(!t.salesEndAt || t.salesEndAt >= now);
return {

View File

@ -5,6 +5,7 @@ import { validate } from '../../middleware/validate';
import { quickJoinRateLimit } from '../../middleware/rate-limit';
import { volunteerInviteService } from './volunteer-invite.service';
import { generateInviteSchema, redeemInviteSchema } from './volunteer-invite.schemas';
import { env } from '../../config/env';
const router = Router();
@ -33,9 +34,16 @@ router.post(
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await volunteerInviteService.redeemInvite(req.body);
// Set refresh token as httpOnly cookie
res.cookie('cml_refresh', result.tokens.refreshToken, {
httpOnly: true,
secure: env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/api/auth',
});
res.json({
accessToken: result.tokens.accessToken,
refreshToken: result.tokens.refreshToken,
cutId: result.cutId,
shiftId: result.shiftId,
});

View File

@ -125,6 +125,7 @@ import { actionItemsRouter } from './modules/meetings/action-items.routes';
import { WebSocketServer } from 'ws';
import { docsCollabService } from './modules/docs/docs-collab.service';
import { correlationId } from './middleware/correlation-id';
import cookieParser from 'cookie-parser';
const app = express();
@ -133,6 +134,7 @@ app.set('trust proxy', 1);
// --- Middleware Stack ---
app.use(correlationId);
app.use(cookieParser());
app.use(helmet({
contentSecurityPolicy: env.CSP_ENABLED === 'true'

View File

@ -510,7 +510,7 @@ services:
image: {{registryUrl}}/mongo:6.0
container_name: {{containerPrefix}}-mongodb
restart: unless-stopped
command: ["mongod", "--replSet", "rs0", "--bind_ip_all", "--auth", "--keyFile", "/data/replica.key"]
entrypoint: ["/bin/bash", "-c", "if [ ! -f /data/replica.key ]; then openssl rand -base64 756 > /data/replica.key; fi && chmod 400 /data/replica.key && chown 999:999 /data/replica.key && exec mongod --replSet rs0 --bind_ip_all --auth --keyFile /data/replica.key"]
environment:
MONGO_INITDB_ROOT_USERNAME: "${MONGO_ROOT_USER:-rocketchat}"
MONGO_INITDB_ROOT_PASSWORD: "${MONGO_ROOT_PASSWORD}"

View File

@ -891,7 +891,7 @@ services:
image: ${GITEA_REGISTRY:-gitea.bnkops.com/admin}/mongo:6.0
container_name: mongodb-rocketchat
restart: unless-stopped
command: ["mongod", "--replSet", "rs0", "--bind_ip_all", "--auth", "--keyFile", "/data/replica.key"]
entrypoint: ["/bin/bash", "-c", "if [ ! -f /data/replica.key ]; then openssl rand -base64 756 > /data/replica.key; fi && chmod 400 /data/replica.key && chown 999:999 /data/replica.key && exec mongod --replSet rs0 --bind_ip_all --auth --keyFile /data/replica.key"]
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USER:-rocketchat}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD must be set in .env}

View File

@ -908,7 +908,7 @@ services:
image: mongo:6.0
container_name: mongodb-rocketchat
restart: unless-stopped
command: ["mongod", "--replSet", "rs0", "--bind_ip_all", "--auth", "--keyFile", "/data/replica.key"]
entrypoint: ["/bin/bash", "-c", "if [ ! -f /data/replica.key ]; then openssl rand -base64 756 > /data/replica.key; fi && chmod 400 /data/replica.key && chown 999:999 /data/replica.key && exec mongod --replSet rs0 --bind_ip_all --auth --keyFile /data/replica.key"]
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USER:-rocketchat}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD must be set in .env}