# Website auth + route protection rethink (class-based single concept) Goal: replace the current mixed system of Next middleware + client guards + demo cookies + alpha mode branches with **one coherent, predictable system** implemented via a small set of **clean, solid classes**. Non-negotiables: - **Server-side is canonical** for access control and redirects. - **Client-side is UX only** (show/hide UI, session-aware components) and never a source of truth. - “Demo” is just **a predefined user account**; no special routing/auth logic. - “Alpha mode” is removed; **feature flags** decide what UI/features are visible. This plan is designed to keep existing integration coverage in [`tests/integration/website/auth-flow.test.ts`](../tests/integration/website/auth-flow.test.ts:1) passing, adjusting tests only when the old behavior was accidental. --- ## 1) Current state (what exists today) ### 1.1 Server-side (Edge middleware) [`apps/website/middleware.ts`](../apps/website/middleware.ts:1) currently: - Treats presence of cookie `gp_session` as “authenticated”. - Uses a hardcoded `publicRoutes` array derived from [`routes`](../apps/website/lib/routing/RouteConfig.ts:114). - Redirects unauthenticated users to `/auth/login?returnTo=...`. - Redirects authenticated users away from `/auth/*` pages based on cookie `gridpilot_demo_mode` (special-case sponsor). Problems: - Cookie presence ≠ valid session (session drift tests exist). - Authorization decisions are made without server-side session validation. - Demo cookies influence routing decisions (non-canonical). ### 1.2 Client-side (guards + AuthContext) - [`apps/website/lib/auth/AuthContext.tsx`](../apps/website/lib/auth/AuthContext.tsx:1) fetches session via `sessionService.getSession()` on mount. - Client-only route wrappers: - [`apps/website/lib/guards/AuthGuard.tsx`](../apps/website/lib/guards/AuthGuard.tsx:1) - [`apps/website/lib/guards/RoleGuard.tsx`](../apps/website/lib/guards/RoleGuard.tsx:1) Problems: - Double guarding: middleware may redirect, and guards may redirect again after hydration (flicker). - Guards treat “wrong role” like “unauthenticated” (this is fine and matches chosen UX), but enforcement is inconsistent. ### 1.3 “Alpha mode” and demo exceptions - [`apps/website/app/layout.tsx`](../apps/website/app/layout.tsx:1) branches on `mode === 'alpha'` and renders a different shell. - Demo logic leaks into routing via `gridpilot_demo_mode` in middleware (and various components). - Tests currently set cookies like `gridpilot_demo_mode`, sponsor id/name, plus drift cookies (see [`tests/integration/website/websiteAuth.ts`](../tests/integration/website/websiteAuth.ts:1)). We will remove all of this: - **No alpha mode**: replaced with feature flags. - **No demo routing exceptions**: demo is a user, not a mode. --- ## 2) Target concept (one clean concept expressed as classes) ### 2.1 Definitions **Authentication** - A request is “authenticated” iff API `/auth/session` (or `/api/auth/session`) returns a valid session object. - The `gp_session` cookie is an opaque session identifier; presence alone is never trusted. **Authorization** - A request is “authorized” for a route iff the session exists and session role satisfies the route requirement. **Canonical redirect behavior (approved)** - If route is protected and user is unauthenticated OR unauthorized (wrong role): - redirect to `/auth/login?returnTo=`. This is intentionally strict and matches the existing integration expectations for role checks. ### 2.2 Where things live (server vs client) **Server-side (canonical)** - Route protection + redirects, implemented in Next App Router **server layouts**. - Route access matrix is defined once and reused. **Client-side (UX only)** - `AuthProvider` holds `session` to render navigation, user pill, etc. - Client may refresh session on demand (after login/logout), but not on every navigation. --- ## 3) Proposed architecture (clean classes) The core idea: build a tiny “auth kernel” for the website that provides: - route access decisions (pure) - server session retrieval (gateway) - redirect URL construction (pure + safe) - route enforcement (guards) These are classes so responsibilities are explicit, testable, and deletions are easy. ### 3.1 Class inventory (what we will build) This section also addresses the hard requirement: - avoid hardcoded route pathnames so we can extend later (e.g. i18n) That means: - internal logic talks in **route IDs / route patterns**, not raw string paths - redirects are built via **route builders** (locale-aware) - policy checks run on a **normalized logical pathname** (locale stripped) #### 3.1.1 `RouteAccessPolicy` **Responsibility:** answer “what does this path require?” Inputs: - `logicalPathname` (normalized path, locale removed; see `PathnameInterpreter`) Outputs: - `isPublic(pathname): boolean` - `isAuthPage(pathname): boolean` (e.g. `/auth/*`) - `requiredRoles(pathname): string[] | null` - `roleHome(role): string` Source of truth for route set: - The existing inventory in [`tests/integration/website/websiteRouteInventory.ts`](../tests/integration/website/websiteRouteInventory.ts:1) must remain consistent with runtime rules. - Canonical route constants remain in [`apps/website/lib/routing/RouteConfig.ts`](../apps/website/lib/routing/RouteConfig.ts:114). Why a class? - Centralizes route matrix and prevents divergence between middleware/guards/layouts. Avoiding hardcoded paths: - `RouteAccessPolicy` should not hardcode strings like `/auth/login`. - It should instead rely on a `RouteCatalog` (below) that exposes route IDs + patterns derived from [`apps/website/lib/routing/RouteConfig.ts`](../apps/website/lib/routing/RouteConfig.ts:114). #### 3.1.2 `ReturnToSanitizer` **Responsibility:** make `returnTo` safe and predictable. - `sanitizeReturnTo(input: string | null, fallbackPathname: string): string` Rules: - Only allow relative paths starting with `/`. - Strip protocol/host if someone passes an absolute URL. - Optionally disallow `/api/*` and static assets. Why a class? - Open redirects become impossible by construction. #### 3.1.3 `SessionGateway` (server-only) **Responsibility:** fetch the canonical session for the current request. - `getSession(): Promise` Implementation details: - Use server-side `cookies()` to read the incoming cookies. - Call same-origin `/api/auth/session` so Next rewrites (see [`apps/website/next.config.mjs`](../apps/website/next.config.mjs:52)) forward to the API. - Forward cookies via the `cookie` header. - Treat any non-OK response as `null` (never throw for auth checks). Why a class? - Encapsulates the “server fetch with forwarded cookies” complexity. #### 3.1.4 `AuthRedirectBuilder` **Responsibility:** construct redirect targets consistently (and locale-aware). - `toLogin({ current }): string` → `?returnTo=` - `awayFromAuthPage({ session }): string` → role home (driver/sponsor/admin) Internally uses: - `RouteAccessPolicy` for roleHome decision - `ReturnToSanitizer` for returnTo - `RoutePathBuilder` (below) so we do not hardcode `/auth/login` or `/dashboard` Why a class? - Eliminates copy/paste `URLSearchParams` and subtle mismatches. #### 3.1.5 `RouteGuard` (server-only) **Responsibility:** enforce the policy by redirecting. - `enforce({ pathname }): Promise` Logic: 1. If `isPublic(pathname)` and not an auth page: allow. 2. If `isAuthPage(pathname)`: - if session exists: redirect to role home - else: allow 3. If protected: - if no session: redirect to login - if `requiredRoles(pathname)` and role not included: redirect to login (approved UX) - else: allow Why a class? - Moves all enforcement into one place. #### 3.1.6 `FeatureFlagService` (server + client) **Responsibility:** replace “alpha mode” with flags. - `isEnabled(flag): boolean` Rules: - Flags can hide UI or disable pages, but **must not** bypass auth. Note: implementation depends on your existing flag system; the plan assumes it exists and becomes the only mechanism. ### 3.1.7 `PathnameInterpreter` (i18n-ready, server-only) **Responsibility:** turn an incoming Next.js `pathname` into a stable “logical” pathname plus locale. - `interpret(pathname: string): { locale: string | null; logicalPathname: string }` Rules: - If later you add i18n where URLs look like `//...`, this class strips the locale prefix. - If you add Next `basePath`, this class can also strip it. This allows the rest of the auth system to remain stable even if the URL structure changes. ### 3.1.8 `RouteCatalog` + `RoutePathBuilder` (no hardcoded strings) **Responsibility:** remove stringly-typed routes from the auth system. `RouteCatalog` exposes: - route IDs (e.g. `auth.login`, `protected.dashboard`, `sponsor.dashboard`, `admin.root`) - route patterns (for matching): sourced from [`apps/website/lib/routing/RouteConfig.ts`](../apps/website/lib/routing/RouteConfig.ts:114) - helpers built on existing matching tools like `routeMatchers` in [`apps/website/lib/routing/RouteConfig.ts`](../apps/website/lib/routing/RouteConfig.ts:193) `RoutePathBuilder` builds locale-aware URLs: - `build(routeId, params?, { locale? }): string` Implementation direction: - Use the existing `routes` object + `buildPath()` in [`apps/website/lib/routing/RouteConfig.ts`](../apps/website/lib/routing/RouteConfig.ts:307) as the underlying canonical mapping. - Add an optional locale prefix when i18n is introduced. With this, auth code never writes literals like `/auth/login`, `/dashboard`, `/sponsor/dashboard`. ### 3.2 How the classes are used (App Router) Route enforcement happens in **server layouts**: - [`apps/website/app/dashboard/layout.tsx`](../apps/website/app/dashboard/layout.tsx:1) - [`apps/website/app/admin/layout.tsx`](../apps/website/app/admin/layout.tsx:1) - [`apps/website/app/sponsor/layout.tsx`](../apps/website/app/sponsor/layout.tsx:1) - [`apps/website/app/profile/layout.tsx`](../apps/website/app/profile/layout.tsx:1) - [`apps/website/app/onboarding/layout.tsx`](../apps/website/app/onboarding/layout.tsx:1) Each layout becomes a small server component wrapper: 1. Instantiate `RouteGuard` with its collaborators. 2. `PathnameInterpreter` produces `{ locale, logicalPathname }`. 3. `await guard.enforce({ logicalPathname, locale })`. 3. Render children. ### 3.3 How matching works without hardcoded paths When `RouteGuard` needs to answer questions like “is this an auth page?” or “does this require sponsor role?”, it should: - Match `logicalPathname` against patterns from `RouteCatalog`. - Prefer the existing matcher logic in `routeMatchers` (see [`apps/website/lib/routing/RouteConfig.ts`](../apps/website/lib/routing/RouteConfig.ts:193)) so dynamic routes like `/leagues/[id]/settings` continue to work. This keeps auth rules stable even if later: - `/auth/login` becomes `/de/auth/login` - or `/anmelden` in German via a localized route mapping because the matching happens against route IDs/patterns, not by string prefix checks. ### 3.4 Middleware becomes minimal (or removed) After server layouts exist, middleware should either be: - **Removed entirely**, or - Reduced to only performance/edge cases (static assets bypass, maybe public route list). Important: middleware cannot reliably call backend session endpoint in all environments without complexity/cost; server layouts can. ### 3.5 Replace alpha mode with feature flags Alpha mode branch currently in [`apps/website/app/layout.tsx`](../apps/website/app/layout.tsx:1) should be removed. Target: - Introduce a feature flags source (existing system in repo) and a small provider. - Feature flags decide: - which navigation items are shown - which pages/features are enabled - which UI shell is used (if we need an “alpha shell”, it’s just a flag) Rules: - Feature flags must not bypass auth/authorization. - Feature flags must be evaluated server-side for initial render, and optionally rehydrated client-side. ### 3.6 Demo user without logic exceptions Replace “demo mode cookies” with: - A standard login flow that returns a normal `gp_session` cookie. - Demo login endpoint remains acceptable in non-production, but it should: - authenticate as a *predefined seeded user* - return a normal session payload - set only `gp_session` - not set or depend on `gridpilot_demo_mode`, sponsor id/name cookies Update all UI that reads `gridpilot_demo_mode` to read session role instead. --- ## 4) Migration plan (implementation sequence, class-driven) This is ordered to keep tests green most of the time and reduce churn. ### Step 0 — Document and freeze behavior - Confirm redirect semantics match integration tests: - unauthenticated protected → `/auth/login?returnTo=...` - wrong-role protected → same redirect - authenticated hitting `/auth/login` → redirect to role home (tests currently assert `/dashboard` or `/sponsor/dashboard`) ### Step 1 — Introduce the classes (incl. i18n-ready routing) - Implement `RouteCatalog` + `RoutePathBuilder` first (removes hardcoded strings, enables i18n later). - Implement `PathnameInterpreter` (normalize pathnames). - Implement `RouteAccessPolicy` + `ReturnToSanitizer` next (pure logic, easy unit tests). - Implement `SessionGateway` (server-only). - Implement `AuthRedirectBuilder` (pure + uses sanitizer/policy). - Implement `RouteGuard` (composition). ### Step 2 — Convert protected layouts to server enforcement using `RouteGuard` ### Step 3 — Fix auth routes and redirects (server-first) ### Step 4 — Remove alpha mode branches and replace with `FeatureFlagService` ### Step 5 — Remove demo cookies and demo logic exceptions ### Step 6 — Simplify or delete middleware - Remove all `gridpilot_demo_mode`, sponsor id/name cookies usage. - Ensure sponsor role is derived from session. ### Step 7 — Update integration tests - If server layouts cover all protected routes, middleware can be deleted. - If kept, it should only do cheap routing (no role logic, no demo logic). ### Step 8 — Delete obsolete code + tighten tests - Update cookie setup in [`tests/integration/website/websiteAuth.ts`](../tests/integration/website/websiteAuth.ts:1): - stop setting demo cookies - keep drift cookies if still supported by API - rely solely on `gp_session` from demo-login - Update expectations in [`tests/integration/website/auth-flow.test.ts`](../tests/integration/website/auth-flow.test.ts:1) only if necessary. ### Step 9 — Run repo verifications - `eslint` - `tsc` - integration tests including [`tests/integration/website/auth-flow.test.ts`](../tests/integration/website/auth-flow.test.ts:1) --- ## 5) Files to remove (expected deletions) These are the primary candidates to delete because they become redundant or incorrect under the new concept. ### 5.1 Website auth/route-protection code to delete - [`apps/website/lib/guards/AuthGuard.tsx`](../apps/website/lib/guards/AuthGuard.tsx:1) - [`apps/website/lib/guards/RoleGuard.tsx`](../apps/website/lib/guards/RoleGuard.tsx:1) - [`apps/website/lib/guards/AuthGuard.test.tsx`](../apps/website/lib/guards/AuthGuard.test.tsx:1) - [`apps/website/lib/guards/RoleGuard.test.tsx`](../apps/website/lib/guards/RoleGuard.test.tsx:1) Rationale: client-side guards are replaced by server-side enforcement in layouts. ### 5.2 Website Next route handlers that conflict with the canonical API auth flow - [`apps/website/app/auth/iracing/start/route.ts`](../apps/website/app/auth/iracing/start/route.ts:1) - [`apps/website/app/auth/iracing/callback/route.ts`](../apps/website/app/auth/iracing/callback/route.ts:1) Rationale: these are placeholder/mocks and should be replaced with a single canonical auth flow via the API. ### 5.3 Website logout route handler (currently incorrect) - [`apps/website/app/auth/logout/route.ts`](../apps/website/app/auth/logout/route.ts:1) Rationale: deletes `gp_demo_session` instead of `gp_session` and duplicates API logout. ### 5.4 Demo-cookie driven UI (to remove or refactor) These files likely contain `gridpilot_demo_mode` logic and must be refactored to session-based logic; if purely demo-only, delete. - [`apps/website/components/dev/DevToolbar.tsx`](../apps/website/components/dev/DevToolbar.tsx:1) (refactor: use session, not demo cookies) - [`apps/website/components/profile/UserPill.tsx`](../apps/website/components/profile/UserPill.tsx:1) (refactor) - [`apps/website/components/sponsors/SponsorInsightsCard.tsx`](../apps/website/components/sponsors/SponsorInsightsCard.tsx:1) (refactor) Note: these are not guaranteed deletions, but demo-cookie logic in them must be removed. ### 5.5 Alpha mode (to remove) - “Alpha mode” branching in [`apps/website/app/layout.tsx`](../apps/website/app/layout.tsx:1) should be removed. Whether any specific “alpha-only” files are deleted depends on feature flag mapping; the hard requirement is: no `mode === 'alpha'` routing/auth exceptions remain. --- ## 6) Acceptance criteria - There is exactly one canonical place where access is enforced: server layouts. - Middleware contains no auth/role/demo logic (or is deleted). - Auth logic has zero hardcoded pathname strings; it relies on route IDs + builders and is i18n-ready. - No code uses `gridpilot_demo_mode` or sponsor-id/name cookies to drive auth/redirect logic. - Demo login returns a normal session; “demo user” behaves like any other user. - Alpha mode is removed; feature flags are used instead. - Integration tests under [`tests/integration/website`](../tests/integration/website/auth-flow.test.ts:1) pass. - Repo checks pass: eslint + tsc + tests.