86 lines
3.6 KiB
JavaScript
86 lines
3.6 KiB
JavaScript
// ─────────────────────────────────────────────────────────────
|
|
// 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;
|