import 'dotenv/config'; import express from 'express'; import cors from 'cors'; import { toNodeHandler } from 'better-auth/node'; import { auth } from './auth/auth.js'; import { authRateLimiter, apiRateLimiter } from './auth/rateLimiter.js'; import chemicalsRouter from './routes/chemicals.js'; import protocolsRouter from './routes/protocols.js'; import profileRouter from './routes/profile.js'; import accountRouter from './routes/account.js'; import path from 'path'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const app = express(); const PORT = process.env.PORT || 3001; const UPLOADS_DIR = process.env.UPLOADS_DIR || path.join(__dirname, '../uploads'); // Trust Cloudflare/proxy X-Forwarded-For headers app.set('trust proxy', 1); app.use(cors({ origin: [ 'http://localhost:5173', 'https://labwise.wahwa.com', ], credentials: true, })); // Serve uploaded files app.use('/uploads', express.static(UPLOADS_DIR)); // iOS Google OAuth initiator — opens in ASWebAuthenticationSession (Safari jar). // This GET endpoint calls Better Auth's sign-in/social internally (server-to-server), // then forwards the state cookie and redirects to Google — all within the same // Safari session. This keeps the state cookie in the same jar that will receive // the Google callback, avoiding the state_mismatch error. app.get('/api/ios-google', async (req, res) => { const callbackURL = (req.query.callbackURL as string) || `https://labwise.wahwa.com/api/ios-callback`; try { const baRes = await fetch(`http://localhost:${PORT}/api/auth/sign-in/social`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Origin': 'https://labwise.wahwa.com' }, body: JSON.stringify({ provider: 'google', callbackURL }), redirect: 'manual', }); // Forward the state cookie Better Auth set, then redirect to Google const setCookie = baRes.headers.get('set-cookie'); if (setCookie) res.setHeader('Set-Cookie', setCookie); const body = await baRes.json() as { url?: string }; if (!body.url) return res.status(500).send('No redirect URL from auth server'); return res.redirect(body.url); } catch (e) { return res.status(500).send('Auth initiation failed'); } }); // iOS OAuth callback — must be registered before the Better Auth wildcard // so Express matches this specific path first. // Better Auth completes the Google flow, sets the session cookie, then // redirects to this endpoint (passed as callbackURL from the native app). // We read the raw session token out of the cookie and forward it in the // custom URL scheme so the iOS app can inject it into URLSession's cookie jar. app.get('/api/ios-callback', (req, res) => { const cookieHeader = req.headers.cookie ?? ''; console.log('[ios-callback] cookies received:', cookieHeader.split(';').map(c => c.trim().split('=')[0])); // Better Auth sets either 'better-auth.session_token' (dev/HTTP) or // '__Secure-better-auth.session_token' (production HTTPS). const token = cookieHeader .split(';') .map(c => c.trim()) .find(c => c.startsWith('better-auth.session_token=') || c.startsWith('__Secure-better-auth.session_token=')) ?.split('=') .slice(1) .join('='); // re-join in case the value itself contains '=' if (!token) { return res.redirect('labwise://auth?error=no_session'); } res.redirect(`labwise://auth?token=${encodeURIComponent(token)}`); }); // Better Auth — must come before express.json() so it can read its own body app.use('/api/auth/*', authRateLimiter); app.all('/api/auth/*', toNodeHandler(auth)); // Body parsing for all other routes app.use(express.json({ limit: '1mb' })); // Application routes app.use('/api', apiRateLimiter); app.use('/api/chemicals', chemicalsRouter); app.use('/api/protocols', protocolsRouter); app.use('/api/profile', profileRouter); app.use('/api/account', accountRouter); app.get('/api/health', (_req, res) => res.json({ ok: true })); app.listen(PORT, () => { console.log(`LabWise API running on http://localhost:${PORT}`); });