17 KiB
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 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 currently:
- Treats presence of cookie
gp_sessionas “authenticated”. - Uses a hardcoded
publicRoutesarray derived fromroutes. - Redirects unauthenticated users to
/auth/login?returnTo=.... - Redirects authenticated users away from
/auth/*pages based on cookiegridpilot_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.tsxfetches session viasessionService.getSession()on mount.- Client-only route wrappers:
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.tsxbranches onmode === 'alpha'and renders a different shell.- Demo logic leaks into routing via
gridpilot_demo_modein middleware (and various components). - Tests currently set cookies like
gridpilot_demo_mode, sponsor id/name, plus drift cookies (seetests/integration/website/websiteAuth.ts).
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_sessioncookie 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=<current path>.
- redirect to
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)
AuthProviderholdssessionto 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; seePathnameInterpreter)
Outputs:
isPublic(pathname): booleanisAuthPage(pathname): boolean(e.g./auth/*)requiredRoles(pathname): string[] | nullroleHome(role): string
Source of truth for route set:
- The existing inventory in
tests/integration/website/websiteRouteInventory.tsmust remain consistent with runtime rules. - Canonical route constants remain in
apps/website/lib/routing/RouteConfig.ts.
Why a class?
- Centralizes route matrix and prevents divergence between middleware/guards/layouts.
Avoiding hardcoded paths:
RouteAccessPolicyshould not hardcode strings like/auth/login.- It should instead rely on a
RouteCatalog(below) that exposes route IDs + patterns derived fromapps/website/lib/routing/RouteConfig.ts.
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<AuthSessionDTO | null>
Implementation details:
- Use server-side
cookies()to read the incoming cookies. - Call same-origin
/api/auth/sessionso Next rewrites (seeapps/website/next.config.mjs) forward to the API. - Forward cookies via the
cookieheader. - 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→<login route>?returnTo=<sanitized current>awayFromAuthPage({ session }): string→ role home (driver/sponsor/admin)
Internally uses:
RouteAccessPolicyfor roleHome decisionReturnToSanitizerfor returnToRoutePathBuilder(below) so we do not hardcode/auth/loginor/dashboard
Why a class?
- Eliminates copy/paste
URLSearchParamsand subtle mismatches.
3.1.5 RouteGuard (server-only)
Responsibility: enforce the policy by redirecting.
enforce({ pathname }): Promise<void>
Logic:
- If
isPublic(pathname)and not an auth page: allow. - If
isAuthPage(pathname):- if session exists: redirect to role home
- else: allow
- 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
/<locale>/..., 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 - helpers built on existing matching tools like
routeMatchersinapps/website/lib/routing/RouteConfig.ts
RoutePathBuilder builds locale-aware URLs:
build(routeId, params?, { locale? }): string
Implementation direction:
- Use the existing
routesobject +buildPath()inapps/website/lib/routing/RouteConfig.tsas 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.tsxapps/website/app/admin/layout.tsxapps/website/app/sponsor/layout.tsxapps/website/app/profile/layout.tsxapps/website/app/onboarding/layout.tsx
Each layout becomes a small server component wrapper:
- Instantiate
RouteGuardwith its collaborators. PathnameInterpreterproduces{ locale, logicalPathname }.await guard.enforce({ logicalPathname, locale }).- 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
logicalPathnameagainst patterns fromRouteCatalog. - Prefer the existing matcher logic in
routeMatchers(seeapps/website/lib/routing/RouteConfig.ts) so dynamic routes like/leagues/[id]/settingscontinue to work.
This keeps auth rules stable even if later:
/auth/loginbecomes/de/auth/login- or
/anmeldenin 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 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_sessioncookie. - 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/dashboardor/sponsor/dashboard)
- unauthenticated protected →
Step 1 — Introduce the classes (incl. i18n-ready routing)
- Implement
RouteCatalog+RoutePathBuilderfirst (removes hardcoded strings, enables i18n later). - Implement
PathnameInterpreter(normalize pathnames). - Implement
RouteAccessPolicy+ReturnToSanitizernext (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:- stop setting demo cookies
- keep drift cookies if still supported by API
- rely solely on
gp_sessionfrom demo-login
-
Update expectations in
tests/integration/website/auth-flow.test.tsonly if necessary.
Step 9 — Run repo verifications
eslinttsc- integration tests including
tests/integration/website/auth-flow.test.ts
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.tsxapps/website/lib/guards/RoleGuard.tsxapps/website/lib/guards/AuthGuard.test.tsxapps/website/lib/guards/RoleGuard.test.tsx
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
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)
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(refactor: use session, not demo cookies)apps/website/components/profile/UserPill.tsx(refactor)apps/website/components/sponsors/SponsorInsightsCard.tsx(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.tsxshould 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_modeor 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/websitepass. - Repo checks pass: eslint + tsc + tests.