This commit is contained in:
2026-01-11 13:04:33 +01:00
parent 6f2ab9fc56
commit 971aa7288b
44 changed files with 2168 additions and 1240 deletions
-287
View File
@@ -1,287 +0,0 @@
# Authentication & Authorization Refactor Summary
## Problem Statement
The website had a "fucking unpredictable mess" of authorization and authentication layers:
- **RouteGuard** (old, complex)
- **AuthGuard** (old, complex)
- **AuthGateway** (deprecated)
- **AuthorizationBlocker** (deprecated)
- **Middleware** with hardcoded paths
- **Role logic scattered** across client and server
- **Inconsistent patterns** across routes
## The Clean Solution
### 1. Centralized Route Configuration
**File:** `apps/website/lib/routing/RouteConfig.ts`
```typescript
// Single source of truth for ALL routes
export const routes = {
dashboard: {
path: '/dashboard',
auth: true,
roles: ['driver', 'team_manager', 'sponsor'],
redirect: '/login'
},
admin: {
path: '/admin',
auth: true,
roles: ['admin'],
redirect: '/unauthorized'
},
// ... and more
}
```
**Benefits:**
- ✅ No hardcoded paths anywhere
- ✅ Type-safe route definitions
- ✅ i18n-ready (switch locales by changing config)
- ✅ Easy to maintain
### 2. Clean Middleware
**File:** `apps/website/middleware.ts`
```typescript
// Before: Complex logic with hardcoded paths
// After: Simple cookie check + redirect using route config
export async function middleware(req: NextRequest) {
const pathname = req.nextUrl.pathname;
// Find matching route
const route = routes.getRouteByPath(pathname);
if (route?.auth && !hasAuthCookie(req)) {
return NextResponse.redirect(new URL(route.redirect, req.url));
}
return NextResponse.next();
}
```
**Benefits:**
- ✅ Uses route config exclusively
- ✅ No role logic in middleware
- ✅ Predictable flow
- ✅ Easy to debug
### 3. Clean Guards (TDD Implementation)
#### AuthGuard
**File:** `apps/website/lib/guards/AuthGuard.tsx`
```typescript
// Only checks authentication
export class AuthGuard {
async check(session: Session | null): Promise<boolean> {
return session !== null;
}
async enforce(session: Session | null): Promise<void> {
if (!await this.check(session)) {
throw new AuthError('Not authenticated');
}
}
}
```
#### RoleGuard
**File:** `apps/website/lib/guards/RoleGuard.tsx`
```typescript
// Only checks roles
export class RoleGuard {
async check(session: Session | null, requiredRoles: string[]): Promise<boolean> {
if (!session?.user?.roles) return false;
return requiredRoles.some(role => session.user.roles.includes(role));
}
async enforce(session: Session | null, requiredRoles: string[]): Promise<void> {
if (!await this.check(session, requiredRoles)) {
throw new AuthorizationError('Insufficient permissions');
}
}
}
```
**Benefits:**
- ✅ Single responsibility
- ✅ Class-based (easy to test)
- ✅ Full TDD coverage
- ✅ Predictable behavior
### 4. Updated Route Layouts
**All 7 layouts updated:**
```typescript
// Before: Mixed old guards, hardcoded paths
import { RouteGuard } from '@/lib/gateways/RouteGuard';
import { AuthGateway } from '@/lib/gateways/AuthGateway';
// After: Clean guards with route config
import { AuthGuard } from '@/lib/guards/AuthGuard';
import { RoleGuard } from '@/lib/guards/RoleGuard';
import { routes } from '@/lib/routing/RouteConfig';
export default async function DashboardLayout({ children }) {
const session = await getSession();
const authGuard = new AuthGuard();
const roleGuard = new RoleGuard();
await authGuard.enforce(session);
await roleGuard.enforce(session, routes.dashboard.roles);
return <>{children}</>;
}
```
### 5. Comprehensive Tests
**TDD Applied:**
- `AuthGuard.test.tsx` - Full coverage
- `RoleGuard.test.tsx` - Full coverage
- `auth-flow-clean.test.ts` - Integration tests
**Test Structure:**
```typescript
describe('AuthGuard', () => {
it('should pass when authenticated', async () => {
const guard = new AuthGuard();
const result = await guard.check(mockSession);
expect(result).toBe(true);
});
it('should fail when not authenticated', async () => {
const guard = new AuthGuard();
await expect(guard.enforce(null)).rejects.toThrow(AuthError);
});
});
```
## Architecture Flow
### Request Flow (Clean)
```
1. User requests /dashboard
↓
2. Middleware checks route config
↓
3. If auth required → check cookie
↓
4. If no cookie → redirect to login
↓
5. If authenticated → load layout
↓
6. AuthGuard.enforce() → verify session
↓
7. RoleGuard.enforce() → verify roles
↓
8. Render page
```
### Old Flow (Chaotic)
```
1. User requests /dashboard
↓
2. Middleware checks hardcoded paths
↓
3. RouteGuard checks (complex logic)
↓
4. AuthGuard checks (duplicate logic)
↓
5. AuthGateway checks (deprecated)
↓
6. AuthorizationBlocker checks
↓
7. Layout guards check again
↓
8. Maybe render, maybe not
```
## Files Created
### New Files
- `apps/website/lib/routing/RouteConfig.ts` - Central routing
- `apps/website/lib/guards/AuthGuard.tsx` - Auth guard
- `apps/website/lib/guards/AuthGuard.test.tsx` - Tests
- `apps/website/lib/guards/RoleGuard.tsx` - Role guard
- `apps/website/lib/guards/RoleGuard.test.tsx` - Tests
- `tests/integration/website/auth-flow-clean.test.ts` - Integration
- `docs/architecture/CLEAN_AUTH_SOLUTION.md` - Architecture guide
### Modified Files
- `apps/website/middleware.ts` - Clean middleware
- `apps/website/app/dashboard/layout.tsx` - Updated
- `apps/website/app/profile/layout.tsx` - Updated
- `apps/website/app/sponsor/layout.tsx` - Updated
- `apps/website/app/onboarding/layout.tsx` - Updated
- `apps/website/app/admin/layout.tsx` - Updated
- `apps/website/app/admin/users/page.tsx` - Updated
### Deleted Files
- ❌ `apps/website/lib/gateways/` (entire directory)
- ❌ `apps/website/lib/blockers/AuthorizationBlocker.ts`
## Key Benefits
### ✅ Predictability
- One clear path for every request
- No hidden logic
- Easy to trace
### ✅ Maintainability
- Single source of truth (RouteConfig)
- No duplication
- Easy to add new routes
### ✅ Testability
- Class-based guards
- Full TDD coverage
- Integration tests
### ✅ Flexibility
- i18n ready
- Role-based access
- Easy to extend
### ✅ Developer Experience
- Type-safe
- Clear errors
- Good documentation
## Migration Checklist
- [x] Analyze current chaos
- [x] Define responsibilities
- [x] Design unified concept
- [x] Create RouteConfig.ts
- [x] Update middleware.ts
- [x] Create AuthGuard
- [x] Create RoleGuard
- [x] Update all layouts
- [x] Write comprehensive tests
- [x] Document architecture
- [x] Verify compilation
- [x] Remove old files
## Next Steps
1. **Start API server** for full integration testing
2. **Run tests** to verify everything works
3. **Test edge cases** (expired sessions, role changes)
4. **Monitor production** for any issues
5. **Document any additional patterns** discovered
## Summary
This refactor transforms the "unpredictable mess" into a **clean, predictable, and maintainable** authentication system:
- **1 central config** instead of scattered paths
- **2 clean guards** instead of 5+ overlapping layers
- **Full TDD coverage** for reliability
- **Clear separation** of concerns
- **Easy to debug** and extend
The architecture is now ready for i18n, new routes, and future enhancements without adding complexity.
-374
View File
@@ -1,374 +0,0 @@
# Clean Authentication & Authorization Solution
## Overview
This document describes the **clean, predictable, and maintainable** authentication and authorization architecture that replaces the previous "fucking unpredictable mess."
## The Problem
**Before:**
- Multiple overlapping protection layers (middleware, RouteGuard, AuthGuard, Blockers, Gateways)
- Hardcoded paths scattered throughout codebase
- Mixed responsibilities between server and client
- Inconsistent patterns across routes
- Role logic in both client and server
- Debugging nightmare with unclear flow
## The Solution
### Core Principle: **Single Source of Truth**
All routing decisions flow through **one centralized configuration system**:
```typescript
// apps/website/lib/routing/RouteConfig.ts
export const routes = {
auth: {
login: '/auth/login',
signup: '/auth/signup',
// ... all auth routes
},
public: {
home: '/',
leagues: '/leagues',
// ... all public routes
},
protected: {
dashboard: '/dashboard',
// ... all protected routes
},
sponsor: {
dashboard: '/sponsor/dashboard',
// ... sponsor routes
},
admin: {
root: '/admin',
users: '/admin/users',
},
league: {
detail: (id: string) => `/leagues/${id}`,
// ... parameterized routes
},
// ... etc
};
```
### Architecture Layers
#### 1. **Edge Middleware** (Simple & Clean)
```typescript
// apps/website/middleware.ts
export function middleware(request: NextRequest) {
const hasAuthCookie = request.cookies.has('gp_session');
// Public routes from config
const publicRoutes = [
routes.public.home,
routes.public.leagues,
routes.auth.login,
// ... etc
];
if (publicRoutes.includes(pathname)) {
// Handle auth route redirects
return NextResponse.next();
}
// Protected routes
if (!hasAuthCookie) {
const loginUrl = new URL(routes.auth.login, request.url);
loginUrl.searchParams.set('returnTo', pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
```
**Responsibilities:**
- ✅ Check session cookie
- ✅ Allow public routes
- ✅ Redirect to login if no cookie
- ❌ No role checking
- ❌ No hardcoded paths
#### 2. **Client Guards** (UX Enhancement)
```typescript
// apps/website/lib/guards/AuthGuard.tsx
export function AuthGuard({ children, requireAuth = true }: AuthGuardProps) {
const { session, loading } = useAuth();
const router = useRouter();
useEffect(() => {
if (requireAuth && !loading && !session) {
const url = new URL(routes.auth.login, window.location.origin);
url.searchParams.set('returnTo', window.location.pathname);
router.push(url.toString());
}
}, [session, loading]);
if (loading) return <LoadingState />;
if (!session && requireAuth) return null;
return <>{children}</>;
}
// apps/website/lib/guards/RoleGuard.tsx
export function RoleGuard({ children, requiredRoles }: RoleGuardProps) {
const { session, loading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!loading && session && !requiredRoles.includes(session.role)) {
router.push(routes.protected.dashboard);
}
}, [session, loading]);
if (loading) return <LoadingState />;
if (!session || !requiredRoles.includes(session.role)) return null;
return <>{children}</>;
}
```
**Responsibilities:**
- ✅ Verify session exists
- ✅ Show loading states
- ✅ Redirect if unauthorized
- ✅ Hide UI elements
- ❌ Make security decisions
#### 3. **API Guards** (Source of Truth)
```typescript
// apps/api/src/domain/auth/AuthorizationGuard.ts
@Injectable()
export class AuthorizationGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const isPublic = this.reflector.getMetadata('public', handler);
if (isPublic) return true;
const request = context.switchToHttp().getRequest();
const userId = request.user?.userId;
if (!userId) {
throw new UnauthorizedException('Authentication required');
}
const rolesMetadata = this.reflector.getMetadata('roles', handler);
if (rolesMetadata) {
const userRoles = this.authorizationService.getRolesForUser(userId);
const hasRole = rolesMetadata.some(r => userRoles.includes(r));
if (!hasRole) {
throw new ForbiddenException('Access denied');
}
}
return true;
}
}
```
**Responsibilities:**
- ✅ Verify authentication
- ✅ Check permissions
- ✅ Return 401/403
- ❌ Redirect
- ❌ Trust client
### Usage Examples
#### Public Route
```typescript
// app/leagues/page.tsx
export default function LeaguesPage() {
return <LeaguesList />;
}
// No protection needed
```
#### Authenticated Route
```typescript
// app/dashboard/layout.tsx
import { AuthGuard } from '@/lib/guards/AuthGuard';
export default function DashboardLayout({ children }) {
return (
<AuthGuard>
<div className="min-h-screen bg-deep-graphite">
{children}
</div>
</AuthGuard>
);
}
// app/dashboard/page.tsx
export default function DashboardPage() {
return <DashboardContent />;
}
```
#### Role-Protected Route
```typescript
// app/admin/layout.tsx
import { AuthGuard } from '@/lib/guards/AuthGuard';
import { RoleGuard } from '@/lib/guards/RoleGuard';
export default function AdminLayout({ children }) {
return (
<AuthGuard>
<RoleGuard requiredRoles={['owner', 'admin']}>
<div className="min-h-screen bg-deep-graphite">
{children}
</div>
</RoleGuard>
</AuthGuard>
);
}
```
#### Scoped Route (League Admin)
```typescript
// app/leagues/[id]/settings/layout.tsx
import { AuthGuard } from '@/lib/guards/AuthGuard';
import { LeagueAccessGuard } from '@/components/leagues/LeagueAccessGuard';
export default function LeagueSettingsLayout({ children, params }) {
return (
<AuthGuard>
<LeagueAccessGuard leagueId={params.id}>
<div className="min-h-screen bg-deep-graphite">
{children}
</div>
</LeagueAccessGuard>
</AuthGuard>
);
}
```
#### API Endpoint
```typescript
// apps/api/src/domain/league/LeagueController.ts
@Controller('leagues')
export class LeagueController {
@Get(':leagueId/admin')
@RequireAuthenticatedUser()
@RequireRoles('admin')
getLeagueAdmin(@Param('leagueId') leagueId: string) {
// Service verifies league-specific permissions
return this.leagueService.getAdminData(leagueId);
}
}
```
## Benefits
### 1. **Predictable Flow**
```
User Request → Middleware (check cookie) → API (auth + authz) → Controller → Response → Client (handle errors)
```
### 2. **Easy Debugging**
```bash
# Check middleware
curl -I http://localhost:3000/dashboard
# Check API auth
curl -I http://localhost:3000/api/admin/users \
-H "Cookie: gp_session=token"
# Check client session
# Browser console: console.log(useAuth().session)
```
### 3. **i18n Ready**
```typescript
// Future: Switch locales by changing config
const routesDe = { ...routes, auth: { login: '/de/auth/login' } };
const routesEs = { ...routes, auth: { login: '/es/auth/login' } };
// All code uses routes.auth.login, so switching is trivial
```
### 4. **Type Safety**
```typescript
// Compile-time checking
routes.league.detail('123'); // ✅ Works
routes.league.detail(); // ❌ Error: requires string
// Parameter validation
const path = buildPath('league.detail', { id: '123' }); // ✅
const path = buildPath('league.detail', {}); // ❌ Error
```
### 5. **Maintainable**
- **One file** to change all routes
- **No hardcoded paths** anywhere else
- **Clear separation** of concerns
- **Easy to test** each layer independently
## Migration Checklist
### Phase 1: Foundation (1 day)
- [x] Create `RouteConfig.ts` with all routes
- [x] Update `middleware.ts` to use route config
- [x] Remove hardcoded paths from middleware
### Phase 2: Guards (2 days)
- [x] Create `AuthGuard.tsx` with route config
- [x] Create `RoleGuard.tsx` with route config
- [x] Remove old `RouteGuard` and `AuthGuard` files
- [x] Remove `AuthGateway` and `AuthorizationBlocker`
### Phase 3: Route Updates (2 days)
- [ ] Update all route layouts to use new guards
- [ ] Remove redundant page-level checks
- [ ] Test all redirect flows
### Phase 4: API Verification (1 day)
- [ ] Ensure all endpoints have proper decorators
- [ ] Add missing `@Public()` or `@RequireRoles()`
- [ ] Test 401/403 responses
### Phase 5: Documentation & Testing (1 day)
- [ ] Update all route protection docs
- [ ] Create testing checklist
- [ ] Verify all scenarios work
## Testing Checklist
### Unauthenticated User
- [ ] `/dashboard` → Redirects to `/auth/login?returnTo=/dashboard`
- [ ] `/admin` → Redirects to `/auth/login?returnTo=/admin`
- [ ] `/leagues` → Works (public)
- [ ] `/auth/login` → Works (public)
### Authenticated User (Regular)
- [ ] `/dashboard` → Works
- [ ] `/admin` → Redirects to `/dashboard` (no role)
- [ ] `/leagues` → Works (public)
- [ ] `/auth/login` → Redirects to `/dashboard`
### Authenticated User (Admin)
- [ ] `/dashboard` → Works
- [ ] `/admin` → Works
- [ ] `/admin/users` → Works
### Session Expiry
- [ ] Navigate to protected route with expired session → Redirect to login
- [ ] Return to original route after login → Works
### API Direct Calls
- [ ] Call protected endpoint without auth → 401
- [ ] Call admin endpoint without role → 403
- [ ] Call public endpoint → 200
## Summary
This architecture eliminates the chaos by:
1. **One Source of Truth**: All routes in `RouteConfig.ts`
2. **Clear Layers**: Middleware → API → Guards → Controller
3. **No Hardcoded Paths**: Everything uses the config
4. **i18n Ready**: Easy to add localized routes
5. **Type Safe**: Compile-time route validation
6. **Easy to Debug**: Each layer has one job
**Result**: Clean, predictable, secure authentication that just works.
-276
View File
@@ -1,276 +0,0 @@
# Quick Reference: Clean Authentication & Authorization
## The Golden Rules
1. **API is the source of truth** - Never trust the client for security
2. **Client is UX only** - Redirect, show loading, hide buttons
3. **One clear flow** - Middleware → API → Guard → Controller
4. **Roles are server-side** - Client only knows "can access" or "can't"
## What Goes Where
### Server-Side (API)
```typescript
// ✅ DO: Check permissions
@RequireRoles('admin')
@Get('users')
getUsers() { ... }
// ✅ DO: Return 401/403
throw new UnauthorizedException('Auth required')
throw new ForbiddenException('No permission')
// ❌ DON'T: Redirect
res.redirect('/login') // Never do this
// ❌ DON'T: Trust client identity
const userId = req.body.userId // Wrong!
const userId = req.user.userId // Correct
```
### Client-Side (Website)
```typescript
// ✅ DO: Redirect unauthenticated users
if (!session && !loading) {
router.push('/auth/login')
}
// ✅ DO: Show loading states
if (loading) return <Loading />
// ✅ DO: Hide UI elements
{canAccess && <button>Delete</button>}
// ❌ DON'T: Make security decisions
if (user.role === 'admin') // Wrong! API decides
// ❌ DON'T: Trust your own checks
// Client checks are UX only, API is the gatekeeper
```
## Route Protection Patterns
### Public Route
```typescript
// app/leagues/page.tsx
export default function LeaguesPage() {
return <LeaguesList />;
}
// No protection needed - accessible by all
```
### Authenticated Route
```typescript
// app/dashboard/layout.tsx
import { AuthLayout } from '@/lib/guards/AuthLayout';
export default function DashboardLayout({ children }) {
return <AuthLayout>{children}</AuthLayout>;
}
// app/dashboard/page.tsx
export default function DashboardPage() {
return <DashboardContent />;
}
// Layout handles auth check, page is clean
```
### Role-Protected Route
```typescript
// app/admin/layout.tsx
import { RoleLayout } from '@/lib/guards/RoleLayout';
export default function AdminLayout({ children }) {
return (
<RoleLayout requiredRoles={['owner', 'admin']}>
{children}
</RoleLayout>
);
}
// app/admin/page.tsx
export default function AdminPage() {
return <AdminDashboard />;
}
// Layout handles role check
```
### Scoped Route (League Admin)
```typescript
// app/leagues/[id]/settings/layout.tsx
import { AuthLayout } from '@/lib/guards/AuthLayout';
import { LeagueAccessGuard } from '@/components/leagues/LeagueAccessGuard';
export default function LeagueSettingsLayout({ children, params }) {
return (
<AuthLayout>
<LeagueAccessGuard leagueId={params.id}>
{children}
</LeagueAccessGuard>
</AuthLayout>
);
}
// Multiple guards for complex scenarios
```
## API Endpoint Patterns
### Public Endpoint
```typescript
@Public()
@Get('pricing')
getPricing() { ... }
// No auth required
```
### Authenticated Endpoint
```typescript
@RequireAuthenticatedUser()
@Get('profile')
getProfile(@User() user: UserEntity) { ... }
// Any logged-in user
```
### Role-Protected Endpoint
```typescript
@RequireRoles('admin')
@Get('users')
getUsers() { ... }
// Only admins
```
### Scoped Endpoint
```typescript
@RequireAuthenticatedUser()
@Get('leagues/:leagueId/admin')
getLeagueAdmin(
@Param('leagueId') leagueId: string,
@User() user: UserEntity
) {
// Check if user is league admin
this.leagueService.verifyLeagueAdmin(leagueId, user.id);
...
}
// Check scope in service
```
## Error Handling
### API Returns
- **401 Unauthorized**: No/invalid session
- **403 Forbidden**: Has session but no permission
- **404 Not Found**: Resource doesn't exist OR non-disclosure
### Client Handles
```typescript
try {
const data = await apiFetch('/api/admin/users');
return data;
} catch (error) {
if (error.message.includes('401')) {
// Redirect to login
window.location.href = '/auth/login';
} else if (error.message.includes('403')) {
// Show access denied
toast.error('You need admin access');
router.push('/dashboard');
} else {
// Show error
toast.error(error.message);
}
}
```
## Common Mistakes
### ❌ Wrong
```typescript
// Client making security decisions
function AdminPage() {
const { session } = useAuth();
if (session?.role !== 'admin') return <AccessDenied />;
return <AdminDashboard />;
}
// API trusting client
@Post('delete')
deleteUser(@Body() body: { userId: string }) {
const userId = body.userId; // Could be anyone!
...
}
// Middleware doing too much
if (user.role === 'admin') { // Wrong place for this!
return NextResponse.next();
}
```
### ✅ Correct
```typescript
// Client handles UX only
function AdminPage() {
return (
<RoleLayout requiredRoles={['admin']}>
<AdminDashboard />
</RoleLayout>
);
}
// API is source of truth
@Post('delete')
@RequireRoles('admin')
deleteUser(@User() user: UserEntity, @Body() body: { userId: string }) {
// user.id is from session, body.userId is target
// Service verifies permissions
...
}
// Middleware only checks auth
if (!hasAuthCookie) {
return redirect('/login');
}
// Let API handle roles
```
## Testing Checklist
### Before Deploy
- [ ] Unauthenticated user can't access protected routes
- [ ] Authenticated user can access their routes
- [ ] Wrong role gets redirected/denied
- [ ] Session expiry redirects to login
- [ ] API returns proper 401/403 codes
- [ ] Public routes work without login
### Quick Test Commands
```bash
# Test API directly
curl -I http://localhost:3000/api/admin/users
# Should return 401 (no auth)
# Test with session
curl -I http://localhost:3000/api/admin/users \
-H "Cookie: gp_session=valid_token"
# Should return 200 or 403 depending on role
# Test public route
curl -I http://localhost:3000/api/leagues/all
# Should return 200
```
## Migration Steps
1. **Simplify middleware** - Remove role logic
2. **Create clean guards** - AuthLayout, RoleLayout
3. **Update layouts** - Replace old RouteGuard
4. **Test all routes** - Check redirects work
5. **Verify API** - All endpoints have proper decorators
## Remember
- **Server**: Security, permissions, data filtering
- **Client**: UX, loading states, redirects
- **Flow**: Always the same, always predictable
- **Debug**: Check each layer in order
**When in doubt**: The API decides. The client just shows what the API says.
@@ -241,10 +241,13 @@ export class CreateLeaguePresenter implements CreateLeagueOutputPort {
The frontend layer contains UI-specific data shapes. None of these cross into the Core.
There are three distinct frontend data concepts:
Important: `apps/website` is a Next.js delivery app with SSR/RSC. This introduces one additional presentation concept to keep server/client boundaries correct.
There are four distinct frontend data concepts:
1. API DTOs (transport)
2. Command Models (user input / form state)
3. View Models (UI display state)
3. View Models (client-only presentation classes)
4. ViewData (template input, serializable)
⸻
@@ -311,6 +314,10 @@ Rules:
• No domain logic
• No mutation after construction
SSR/RSC rule (website-only):
• View Models are client-only and MUST NOT cross server-to-client boundaries.
• Templates MUST NOT accept View Models.
⸻
8.4 Website Presenters (DTO → ViewModel)
@@ -427,8 +434,9 @@ UI Component
• Core has NO Models, DTOs, or ViewModels
• API talks ONLY to Application Services
• Controllers NEVER call Use Cases directly
• Frontend Components see ONLY View Models
• DTOs never cross into UI components
• Frontend Components see ONLY ViewData (Templates) or ViewModels (Client orchestrators)
• API DTOs never cross into Templates
• View Models never cross into Templates
⸻
@@ -439,4 +447,22 @@ Application Services orchestrate.
Adapters translate.
UI presents.
If a class violates more than one of these roles, it is incorrectly placed.
If a class violates more than one of these roles, it is incorrectly placed.
8.3.1 ViewData (Template Input)
ViewData is the only allowed input for Templates in `apps/website`.
Definition:
• JSON-serializable data structure
• Contains only primitives/arrays/plain objects
• Ready to render: Templates perform no formatting and no derived computation
Rules:
• ViewData is built in client code from:
1) Page DTO (initial SSR-safe render)
2) ViewModel (post-hydration enhancement)
• ViewData MUST NOT contain ViewModel instances or Display Object instances.
Authoritative details:
• [docs/architecture/website/VIEW_DATA.md](docs/architecture/website/VIEW_DATA.md:1)
• [plans/nextjs-rsc-viewmodels-concept.md](plans/nextjs-rsc-viewmodels-concept.md:1)
@@ -4,6 +4,13 @@
A **Display Object** encapsulates **reusable, UI-only display logic**.
In this codebase, a Display Object is a **Frontend Value Object**:
- class-based
- immutable
- deterministic
- side-effect free
It answers the question:
> “How should this specific piece of information be shown?”
@@ -23,12 +30,23 @@ A Display Object MAY:
- encapsulate UI display conventions
- be reused across multiple View Models
In addition, a Display Object MAY:
- normalize presentation inputs (for example trimming/casing)
- expose multiple explicit display variants (for example `shortLabel`, `longLabel`)
A Display Object MUST:
- be deterministic
- be side-effect free
- operate only on presentation data
A Display Object MUST:
- be implemented as a **class** with a small, explicit API
- accept only primitives/plain data in its constructor (or static factory)
- expose only primitive outputs (strings/numbers/booleans)
---
## Restrictions
@@ -42,6 +60,13 @@ A Display Object MUST NOT:
- be sent back to the server
- depend on backend or infrastructure concerns
In this repository, a Display Object MUST NOT:
- call `Intl.*`
- call `Date.toLocaleString()` / `Date.toLocaleDateString()` / `Date.toLocaleTimeString()`
Reason: these are runtime-locale/timezone dependent and cause SSR/hydration mismatches.
If a rule affects system correctness or persistence,
it does not belong in a Display Object.
@@ -53,6 +78,10 @@ it does not belong in a Display Object.
- They are frontend-only
- They are not shared with the backend or core
Placement rule (strict):
- Display Objects live under `apps/website/lib/display-objects/*`.
---
## Relationship to View Models
@@ -62,6 +91,13 @@ it does not belong in a Display Object.
- Display Objects represent **parts**
- View Models represent **screens**
Additional strict rules:
- View Models SHOULD compose Display Objects.
- Display Objects MUST NOT be serialized or passed across boundaries.
- They must not appear in server-to-client DTOs.
- Templates should receive primitive display outputs, not Display Object instances.
---
## Testing
@@ -72,6 +108,11 @@ Display Objects SHOULD be tested because they often contain:
- formatting rules
- edge cases visible to users
Additionally:
- test determinism by running the same inputs under Node and browser contexts (where applicable)
- test boundary rules (no `Intl.*`, no `toLocale*`)
---
## Summary
@@ -79,4 +120,6 @@ Display Objects SHOULD be tested because they often contain:
- Display Objects encapsulate **how something looks**
- View Models encapsulate **what a screen needs**
- Both are presentation concerns
- Neither contains business truth
- Neither contains business truth
In one sentence: Display Objects are **Value Objects for UI display**, not utility functions.
+46
View File
@@ -0,0 +1,46 @@
# ViewData (Website Templates)
ViewData is the **only** allowed input type for Templates in `apps/website`.
## 1) Definition
ViewData is a JSON-serializable, template-ready data structure:
- primitives (strings/numbers/booleans)
- arrays and plain objects
- `null` for missing values
## 2) What ViewData is NOT
ViewData is not:
- a Page DTO (raw transport)
- a ViewModel (client-only class)
- a Display Object instance
## 3) Construction rules
ViewData MUST be created in client code:
1) Initial SSR-safe render: `ViewData = fromDTO(PageDTO)`
2) Post-hydration render: `ViewData = fromViewModel(ViewModel)`
Templates MUST NOT compute derived values.
## 4) Determinism rules
Any formatting used to produce ViewData MUST be deterministic.
Forbidden anywhere in formatting code paths:
- `Intl.*`
- `Date.toLocaleString()` / `Date.toLocaleDateString()` / `Date.toLocaleTimeString()`
Reason: SSR and browser outputs can differ.
## 5) Relationship to Display Objects
Display Objects are used to implement formatting/mapping, but their instances MUST NOT be stored inside ViewData.
Only primitive outputs produced by Display Objects may appear in ViewData.
@@ -24,6 +24,8 @@ A View Model MAY:
- handle localization and presentation logic
- use Display Objects for reusable UI concerns
In the website SSR/RSC architecture, View Models MAY compute view-only derived values, but MUST NOT be the type passed into Templates.
A View Model MUST:
- be fully usable by the UI without further computation
@@ -58,11 +60,20 @@ that logic belongs in the Core, not here.
## Creation Rules
- View Models are created from API DTOs
- DTOs never reach pages/components; map DTO → ViewModel in website services
- UI components must never construct View Models themselves
- Construction happens in services or presentation layers
- The UI only consumes View Models, never DTOs
This repository distinguishes **Page DTO**, **ViewModel**, and **ViewData**:
- Page DTO: server-to-client payload (JSON-serializable)
- ViewModel: client-only class (never serialized)
- ViewData: template input (JSON-serializable)
Rules (website):
1) View Models are created in client code only.
2) View Models are created from Page DTOs.
3) Templates MUST NOT accept View Models; Templates accept ViewData only.
4) View Models MUST compose Display Objects and produce ViewData (primitive outputs only).
Authoritative reference: [plans/nextjs-rsc-viewmodels-concept.md](plans/nextjs-rsc-viewmodels-concept.md:1).
---
@@ -83,4 +94,4 @@ View Models do NOT need tests if they only expose data without logic.
- View Models describe **UI state**
- They are **presentation-focused**, not business-focused
- They reduce complexity in components
- They form a stable contract for the UI
- They form a stable contract for the UI
@@ -0,0 +1,30 @@
# Website DI Rules (Inversify)
This repo uses Inversify DI under [apps/website/lib/di](apps/website/lib/di/index.ts:1).
## 1) Non-negotiable safety rule
No stateful service instances may be shared across requests.
Reason: Next.js server execution is concurrent; shared state causes cross-request leakage.
## 2) Rules by module type
### 2.1 `page.tsx` (server)
- MUST NOT access the DI container directly.
- MUST call a PageQuery only.
### 2.2 Page Queries (server)
- SHOULD prefer explicit construction (manual wiring).
- MAY use DI only if all resolved services are stateless and safe for concurrent requests.
### 2.3 Client modules
- MAY use DI via `ContainerProvider` and hooks (example: `useInject`).
## 3) Container singleton warning
[`ContainerManager`](apps/website/lib/di/container.ts:61) holds a singleton container. Treat it as **unsafe for server request scope** unless proven otherwise.
@@ -0,0 +1,30 @@
# Website Guardrails (Mandatory)
This document defines architecture guardrails that must be enforced via tests + ESLint.
## 1) RSC boundary guardrails
Fail CI if any `apps/website/app/**/page.tsx`:
- imports from `apps/website/lib/view-models/*`
- calls `Intl.*` or `toLocale*`
- performs sorting/filtering (`sort`, `filter`, `reduce`) beyond trivial null checks
## 2) Template purity guardrails
Fail CI if any `apps/website/templates/**`:
- imports from `apps/website/lib/view-models/*`
- imports from `apps/website/lib/display-objects/*`
- calls `Intl.*` or `toLocale*`
Templates accept ViewData only.
## 3) Display Object guardrails
Fail CI if any `apps/website/lib/display-objects/**`:
- calls `Intl.*` or `toLocale*`
Display Objects must be deterministic.
@@ -0,0 +1,39 @@
# Website Page Queries (Server)
This document defines the only allowed server-side data fetching shape for `apps/website` routes.
## 1) Purpose
Page Queries are server-side composition classes that:
- call services that call `apps/api`
- assemble a Page DTO
- return an explicit result describing route outcome
They do not implement business rules.
## 2) Result type (no null)
Page Queries MUST return a discriminated union (`PageQueryResult`):
- `ok` with `{ dto }`
- `notFound`
- `redirect` with `{ to }`
- `error` with `{ errorId }`
Pages MUST switch on this result and call:
- `notFound()` for `notFound`
- `redirect()` for `redirect`
## 3) Forbidden responsibilities
Page Queries MUST NOT:
- format values for display
- sort/filter (canonical or view-only)
- instantiate ViewModels
- instantiate Display Objects
If sorting/filtering is needed, it MUST be added to `apps/api`.
@@ -0,0 +1,60 @@
# Website RSC Presentation Architecture (Strict)
This document defines the only allowed presentation architecture for `apps/website` (Next.js App Router).
It is **website-only** and does not change `apps/api` or `core` architecture.
## 1) Core rule: API owns business truth
- `apps/api` is the only source of truth for business rules and canonical filtering/sorting.
- `apps/website` is presentation infrastructure: composition, routing, caching, and rendering.
## 2) The three website presentation data types
### 2.1 Page DTO
**Purpose:** server-to-client payload.
**Rules:**
- JSON-serializable only.
- Contains raw values only (ISO date strings, numbers, codes).
- MUST NOT contain class instances.
### 2.2 ViewModel
**Purpose:** client-only presentation model.
**Rules:**
- Class-based.
- Instantiated only in `'use client'` modules.
- Composes Display Objects.
- NEVER passed into Templates.
### 2.3 ViewData
**Purpose:** Template input.
**Rules:**
- JSON-serializable only.
- Contains only values ready to render (mostly strings/numbers).
- Built from Page DTO (initial render) and from ViewModel (post-hydration).
## 3) Required per-route structure
Every route MUST follow:
1) `page.tsx` (server): calls a PageQuery and passes Page DTO
2) `*PageClient.tsx` (client): builds ViewData and renders Template
3) `*Template.tsx` (pure UI): renders ViewData only
## 4) Authoritative specification
This document is an entry point only.
The authoritative, test-enforced spec lives at:
- [plans/nextjs-rsc-viewmodels-concept.md](plans/nextjs-rsc-viewmodels-concept.md:1)