// ───────────────────────────────────────────────────────────── // src/auth.js — /auth/* endpoints. // // POST /auth/login { email } → 200 {ok, firstName?} on success, // 403 {error: "not_invited"} otherwise. // POST /auth/logout → 200 // GET /auth/me → 200 {email, firstName} | 401 // // There are no one-time codes anymore — the site is invite-list-only // and the invite list IS the authentication factor. A person who knows // an invited email can log in as them. The site exposes only // marketing/preview content, so this is acceptable by design. If a user // needs to be kicked out, remove their invite AND delete their session // rows (see OPERATIONS.md). // ───────────────────────────────────────────────────────────── import { Router } from 'express'; import { q } from './db.js'; import { issueSession, clearSession } from './sessions.js'; import { rateLimit } from './middleware.js'; const router = Router(); const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; // ─── POST /auth/login ──────────────────────────────────────── // One API call handles the whole flow. On success we set the // session cookie AND return the user's first name so the frontend // can greet them without a second round-trip to /auth/me. // // Rate limit: 30 login attempts per IP per hour. Enough to cover // legitimate re-logins after cookie loss, low enough to deter // scripted enumeration of the invite list. router.post( '/login', rateLimit({ key: (req) => `login:${req.ip}`, max: 30, windowMs: 60 * 60 * 1000, }), (req, res) => { const email = String(req.body?.email || '').trim().toLowerCase(); if (!EMAIL_RE.test(email)) { return res.status(400).json({ error: 'invalid_email' }); } const invited = q.getInvite.get(email); if (!invited) { // Honest "not invited" — we've decided enumeration is not a // concern for this site (invite-list-only, preview content). return res.status(403).json({ error: 'not_invited' }); } issueSession(req, res, email); return res.status(200).json({ ok: true, firstName: invited.first_name || null, }); } ); // ─── POST /auth/logout ─────────────────────────────────────── router.post('/logout', (req, res) => { clearSession(req, res); return res.status(200).json({ ok: true }); }); // ─── GET /auth/me ──────────────────────────────────────────── // Returns the current user's email + first name, or 401 if no // valid session. The frontend calls this on page load to decide // which step of the entrance shell to show. router.get('/me', (req, res) => { const s = req.cookies?.fenja_session ? q.getSession.get(req.cookies.fenja_session, Date.now()) : null; if (!s) return res.status(401).end(); // Look up the invite row to fetch first_name. If the invite was // deleted after the session was issued, first_name will be null // and the frontend falls back to anonymous copy. const invited = q.getInvite.get(s.email); return res.json({ email: s.email, firstName: invited?.first_name || null, }); }); export default router;