diff --git a/.env.example b/.env.example
index 66c1eef..c40fddd 100644
--- a/.env.example
+++ b/.env.example
@@ -82,5 +82,29 @@ PORT=3000
# which would have published a sitemap full of localhost URLs.
NEXT_PUBLIC_SITE_URL=https://www.fedkiit.com
+# --- Tunables ---------------------------------------------------------------
+# All optional. Every default below reproduces the previous hardcoded value, so
+# leaving them unset changes nothing.
+
+# One-time passwords. NEXT_PUBLIC so OtpInput draws exactly this many boxes —
+# one variable for both sides means the code length and the UI cannot desync.
+NEXT_PUBLIC_OTP_LENGTH=4
+OTP_VALIDITY_MINUTES=15
+
+# Extra hosts allowed to appear in an Origin header when building an emailed
+# invite link. Comma-separated. The canonical site host, localhost and 127.0.0.1
+# are always trusted; this is for staging and preview deployments.
+# e.g. TRUSTED_ORIGIN_HOSTS=staging.fedkiit.com,fed-frontend.vercel.app
+TRUSTED_ORIGIN_HOSTS=
+
+# Addresses that may read form analytics regardless of role. Comma-separated.
+# Previously the literal srex@fedkiit.com in the route.
+FORM_ANALYTICS_ALLOWED_EMAILS=srex@fedkiit.com
+
+# Calendar month (1-12) the academic year rolls over in. Used to derive the year
+# of study from a KIIT roll number, so a 2022 intake stays 4th Year until July
+# 2026 rather than being promoted every 1 January.
+ACADEMIC_YEAR_START_MONTH=7
+
LOG_REQ=false
DEBUG=false
diff --git a/MIGRATION.md b/MIGRATION.md
index 4af73cd..7344ade 100644
--- a/MIGRATION.md
+++ b/MIGRATION.md
@@ -74,6 +74,58 @@ These redirects live in `proxy.ts`, not `next.config.ts`: Next matches a
redirect `source` case-insensitively, so a rule from `/Events` to `/events` also
matches `/events` and loops forever.
+## Route guards — the redirect after sign-in
+
+The original never navigated from inside `Login.jsx`. It called
+`authCtx.login(...)` and let the **route table** react:
+
+```jsx
+ : } />
+```
+
+App Router routes are files, so nothing observes `isLoggedIn`, and `proxy.ts`
+only runs on a server request — which a client-side sign-in never makes. The
+first cut of this port dropped the behaviour: a correct login showed "Login
+successful" and then sat on `/Login` forever.
+
+**The redirect belongs in the components, not in a layout wrapper.** A guard in
+`app/(auth)/layout.jsx` reacting to `isLoggedIn` was tried first and is wrong:
+`SignUP.jsx` and `CompleteProfile.jsx` sign the user in and then navigate
+themselves to `/`, and a layout guard cancels that in-flight `router.push`
+before it commits. Measured on the signup flow — the push never reached
+`history` at all, and a new account landed on `/profile` instead of `/`:
+
+```
+20494ms resolve /api/auth/register
+21025ms history.replaceState(/Login?next=%2Fprofile) <- guard won
+ (no history.pushState(/) — SignUp's own push was discarded)
+```
+
+No delay fixes that reliably, because the push only commits once its RSC
+payload arrives. So each component owns its own navigation, which is how the
+ported source was already written: `Login.jsx`, `GoogleLogin.jsx` and
+`GoogleSignup.jsx` all carry `shouldNavigate` / `navigatePath` state and an
+effect that acts on it — dead code in the original precisely *because* the route
+table did the job. Setting `setShouldNavigate(true)` after `authCtx.login(...)`
+brings it to life. `SendOtp.jsx` already did exactly this and needed no change.
+
+`src/utils/postAuthRedirect.js` resolves the destination the way `LoginRedirect`
+did, plus the `?next=` the proxy appends. Because that value now comes off the
+query string it is attacker-supplied, so anything that is not a plain internal
+path is discarded — `//evil.com` included.
+
+Verified in the browser by driving the real forms with the API stubbed at the
+XHR layer:
+
+| Flow | Start | Lands on |
+|---|---|---|
+| Login | `/Login` | **`/profile`** |
+| Login | `/Login?next=/Events` | **`/Events`** |
+| Login | `/Login?next=//example.com/phish` | **`/profile`** — origin preserved |
+| Login | blocked page → login | **back to the blocked page**, `prevPage` cleared |
+| Signup | `/SignUp` | **`/`** — matches the original |
+| Login | stale localStorage, no cookie | **login form, one bounce, no loop** |
+
---
## What was ported
@@ -265,8 +317,807 @@ Two further problems surfaced while verifying, both inherited from the original
length. The Express controller checked only for presence, so `email: "bad"` was
accepted and wrote unreplyable rows into `contactus`.
+## Auth routes — verified
+
+Every auth route was exercised against the running server, signed out and
+signed in. `proxy.ts` is the gate; the numbers below are what it returned.
+
+| Route | Signed out | Signed in |
+|---|---|---|
+| `/Login` `/SignUp` `/ForgotPassword` `/completeProfile` `/otp` | 200 | **307 → `/profile`** |
+| `/profile` and all six sub-pages | **307 → `/Login?next=…`** | 200 |
+| `/login` `/signup` `/forgotpassword` `/completeprofile` | 308 → canonical casing | — |
+
+A forged or expired token is treated as no token, and the bad cookie is cleared
+on the way out:
+
+```
+GET /profile Cookie: token=
+307 → /Login?next=%2Fprofile
+set-cookie: token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT
+```
+
+The seven auth endpoints reject malformed input rather than failing open —
+`login`, `register`, `verifyEmail`, `forgotPassword` and `googleAuth` all
+answer 400 on an empty body, and `logout` is idempotent. `changePassword` is
+the reset step and is gated on a single-use OTP, rate limited, and returns the
+same message whether or not the account exists.
+
+## Load time — the barrel files were the problem
+
+The landing page was shipping **2.3 MB of JavaScript**. The cause is visible in
+any dev-server warning trace:
+
+```
+./src/sections/Profile/Admin/View/VerifyCertificate/VerifyCertificate.jsx
+./src/sections/Profile/index.jsx
+./src/sections/index.jsx <- re-exports Home *and* Profile
+./src/views/Home/Home.jsx
+./app/(main)/page.jsx
+```
+
+`Home.jsx` imported `{ Hero, About, Sponser, Feedback, Contact }` from the
+`sections` barrel, which also re-exports `sections/Profile` — the entire admin
+panel. Every one of those is a client component, so the bundler pulled the whole
+graph into the landing page: certificate tooling, admin tables, the avatar
+editor, event analytics. A visitor who only wanted the hero image downloaded the
+admin panel. The `features` barrel did the same thing for `LiveEventPopup`.
+
+Under Vite this cost nothing noticeable, because the dev server serves ES modules
+untouched and the SPA loaded one bundle for every route anyway. Under Next each
+route gets its own bundle, so a barrel import silently undoes the code splitting.
+
+Fixed by importing the four components directly instead of through a barrel. The
+barrels are untouched — other call sites still use them.
+
+| Page | Before | After |
+|---|---|---|
+| `/` | 2317 KB | **1082 KB** |
+| `/Events` | 2063 KB | **1082 KB** |
+| `/Team` | 2014 KB | **1082 KB** |
+| `/Login` | 1248 KB | **920 KB** |
+
+Uncompressed. Over the wire the landing page is **327 KB** of JS and 55 KB of
+HTML, and locally serves in TTFB 38 ms / DOMContentLoaded 135 ms / load 536 ms.
+
+**Dev-server slowness is separate and expected.** `next dev` compiles each route
+on first request, so a cold page can take seconds while production serves the
+same page in 5–30 ms. Measure `npm run build && npm start`, never `npm run dev`.
+
+## Invalid HTML nesting that only mattered under SSR — all of it
+
+Four components wrapped block-level content in a `
` |
+
+Client-rendered under Vite none of this mattered: React builds the DOM node by
+node, and nothing reparents a tree that already exists. Server-rendered it is
+real markup, so the parser closes the `
` at the first block child and the
+content lands as a *sibling* — a different layout, which React then reports as a
+hydration mismatch.
+
+Each wrapper is now a `
` carrying a class listed alongside the original
+`p` selector, so the computed styles are unchanged. Verified in the browser:
+
+| | Was styled by | Now computes to |
+|---|---|---|
+| `EventCard .meta` | `.eventname p` | 14.4px / flex / center / 1.6px |
+| `EventModal .meta` | `.eventname p` | 14.4px / flex / center / 1.6px / #fff |
+| `Hero .tagline` | `.largeContent p` | 39.2px / 700 / #fff |
+| `Social .content` | `.text p` | 40px / 600 / #fff / center |
+
+Hero keeps its `
` rather than downgrading it to a `` — the wrapper
+changed instead, so the heading still counts as a heading.
+
+`Social`'s wrapper is worth a note: `styles.content` had **no rule in the
+stylesheet**, so the className resolved to `undefined` and did nothing — the
+element was styled purely by `.text p`. `.content` now exists and carries those
+declarations. (`EventCardModal.price` is undefined in the same way; both are
+inherited from the original and left as they are.)
+
+**`npm run audit:nesting` keeps it that way.** `scripts/audit-nesting.mjs`
+walks a tag stack through every JSX file and reports any element the HTML parser
+would reparent. It skips comments, string and regex literals — without that, a
+comment mentioning `