import pkg from '@supabase/ssr'; const { createServerClient } = pkg; import { type Handle, redirect } from '@sveltejs/kit'; import { sequence } from '@sveltejs/kit/hooks'; import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'; import { env } from '$env/dynamic/private'; import type { Database } from '$lib/types/database'; import { supabaseAdmin } from '$lib/server/supabase'; // Cache for setup check to avoid hitting DB on every request let setupCheckCache: { needsSetup: boolean; checkedAt: number } | null = null; const SETUP_CACHE_TTL = 60000; // 1 minute cache // Use internal URL for server-side operations (Docker network), fallback to public URL const SERVER_SUPABASE_URL = env.SUPABASE_INTERNAL_URL || PUBLIC_SUPABASE_URL; /** * Supabase authentication hook * Sets up the Supabase client with cookie handling for SSR */ const supabaseHandle: Handle = async ({ event, resolve }) => { event.locals.supabase = createServerClient( SERVER_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { cookies: { getAll: () => event.cookies.getAll(), setAll: (cookiesToSet) => { cookiesToSet.forEach(({ name, value, options }) => { event.cookies.set(name, value, { ...options, path: '/' }); }); } } } ); /** * Safe session getter that validates the JWT * Returns session, user, and member data */ event.locals.safeGetSession = async () => { const { data: { session } } = await event.locals.supabase.auth.getSession(); if (!session) { return { session: null, user: null, member: null }; } // Validate the session by getting the user const { data: { user }, error: userError } = await event.locals.supabase.auth.getUser(); if (userError || !user) { return { session: null, user: null, member: null }; } // Fetch member profile with dues status const { data: member } = await event.locals.supabase .from('members_with_dues') .select('*') .eq('id', user.id) .single(); return { session, user, member }; }; return resolve(event, { filterSerializedResponseHeaders(name) { return name === 'content-range' || name === 'x-supabase-api-version'; } }); }; /** * Setup check hook * Redirects to /setup if no users exist in the system */ const setupCheckHandle: Handle = async ({ event, resolve }) => { const path = event.url.pathname; // Always allow access to setup page and static assets if (path === '/setup' || path.startsWith('/api/') || path.startsWith('/auth/')) { return resolve(event); } // Check cache first const now = Date.now(); if (setupCheckCache && (now - setupCheckCache.checkedAt) < SETUP_CACHE_TTL) { if (setupCheckCache.needsSetup) { throw redirect(303, '/setup'); } return resolve(event); } // Check if any users exist try { const { count, error } = await supabaseAdmin .from('members') .select('*', { count: 'exact', head: true }); if (error) { console.error('Error checking for existing users:', error); // On error, continue without redirect (fail open) return resolve(event); } const needsSetup = !count || count === 0; setupCheckCache = { needsSetup, checkedAt: now }; if (needsSetup) { throw redirect(303, '/setup'); } } catch (err) { // If it's a redirect, rethrow it if (err && typeof err === 'object' && 'status' in err) { throw err; } console.error('Error in setup check:', err); } return resolve(event); }; /** * Authorization hook * Protects routes based on authentication and role requirements */ const authorizationHandle: Handle = async ({ event, resolve }) => { const { session, member } = await event.locals.safeGetSession(); const path = event.url.pathname; // Setup page is handled by setupCheckHandle if (path === '/setup') { return resolve(event); } // API routes handle their own authentication if (path.startsWith('/api/')) { return resolve(event); } // Auth callback routes should always be accessible if (path.startsWith('/auth/')) { return resolve(event); } // Logout route should always be accessible if (path === '/logout') { return resolve(event); } // Protected routes - require authentication const protectedPrefixes = ['/dashboard', '/profile', '/payments', '/documents', '/board', '/admin']; const isProtectedRoute = protectedPrefixes.some((prefix) => path.startsWith(prefix)); if (isProtectedRoute && !session) { throw redirect(303, `/login?redirectTo=${encodeURIComponent(path)}`); } // Handle authenticated users without a member profile // This can happen if member record creation failed or was deleted if (isProtectedRoute && session && !member) { console.error('Authenticated user has no member profile:', session.user?.id); // Sign them out and redirect to login with an error await event.locals.supabase.auth.signOut(); throw redirect(303, '/login?error=no_profile'); } // Board routes - require board or admin role if (path.startsWith('/board') && member) { if (member.role !== 'board' && member.role !== 'admin') { throw redirect(303, '/dashboard'); } } // Admin routes - require admin role if (path.startsWith('/admin') && member) { if (member.role !== 'admin') { throw redirect(303, '/dashboard'); } } // Redirect authenticated users away from auth pages if (session && (path === '/login' || path === '/signup')) { throw redirect(303, '/dashboard'); } return resolve(event); }; export const handle: Handle = sequence(supabaseHandle, setupCheckHandle, authorizationHandle);