docs
This commit is contained in:
51
docs/architecture/shared/AUTH_CONTRACT.md
Normal file
51
docs/architecture/shared/AUTH_CONTRACT.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Authentication and Authorization (Shared Contract)
|
||||
|
||||
This document defines the shared, cross-app contract for authentication and authorization.
|
||||
|
||||
It does not define Next.js routing details or NestJS guard wiring.
|
||||
|
||||
App-specific documents:
|
||||
|
||||
- API enforcement: [`docs/architecture/api/AUTH_FLOW.md`](docs/architecture/api/AUTH_FLOW.md:1)
|
||||
- Website UX flow: [`docs/architecture/website/WEBSITE_AUTH_FLOW.md`](docs/architecture/website/WEBSITE_AUTH_FLOW.md:1)
|
||||
|
||||
## 1) Core principle (non-negotiable)
|
||||
|
||||
The API is the single source of truth for:
|
||||
|
||||
- who the actor is
|
||||
- what the actor is allowed to do
|
||||
|
||||
The website may improve UX. It does not enforce security.
|
||||
|
||||
## 2) Authentication (strict)
|
||||
|
||||
Authentication answers:
|
||||
|
||||
- who is this actor
|
||||
|
||||
Rules:
|
||||
|
||||
- the actor identity is derived from the authenticated session
|
||||
- the client must never be allowed to claim an identity
|
||||
|
||||
## 3) Authorization (strict)
|
||||
|
||||
Authorization answers:
|
||||
|
||||
- is this actor allowed to perform this action
|
||||
|
||||
Rules:
|
||||
|
||||
- authorization is enforced in the API
|
||||
- the website may hide or disable UI, but cannot enforce correctness
|
||||
|
||||
See: [`docs/architecture/api/AUTHORIZATION.md`](docs/architecture/api/AUTHORIZATION.md:1)
|
||||
|
||||
## 4) Shared terminology (hard)
|
||||
|
||||
- Guard: API enforcement mechanism
|
||||
- Blocker: website UX prevention mechanism
|
||||
|
||||
Shared contract: [`docs/architecture/shared/BLOCKERS_AND_GUARDS.md`](docs/architecture/shared/BLOCKERS_AND_GUARDS.md:1)
|
||||
|
||||
60
docs/architecture/shared/BLOCKERS_AND_GUARDS.md
Normal file
60
docs/architecture/shared/BLOCKERS_AND_GUARDS.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Blockers and Guards (Shared Contract)
|
||||
|
||||
This document defines the **shared contract** for Blockers (website UX) and Guards (API enforcement).
|
||||
|
||||
If a more specific document conflicts with this one, this shared contract wins.
|
||||
|
||||
Related:
|
||||
|
||||
- API authorization: [`docs/architecture/api/AUTHORIZATION.md`](docs/architecture/api/AUTHORIZATION.md:1)
|
||||
- Website delivery-layer contract: [`docs/architecture/website/WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:1)
|
||||
|
||||
## 1) Core principle (non-negotiable)
|
||||
|
||||
Guards enforce. Blockers prevent.
|
||||
|
||||
- Guards protect the system.
|
||||
- Blockers protect the UX.
|
||||
|
||||
There are no exceptions.
|
||||
|
||||
## 2) Definitions (strict)
|
||||
|
||||
### 2.1 Guard (API)
|
||||
|
||||
A Guard is an **API mechanism** that enforces access or execution rules.
|
||||
|
||||
If a Guard denies execution, the request does not reach application logic.
|
||||
|
||||
### 2.2 Blocker (Website)
|
||||
|
||||
A Blocker is a **website mechanism** that prevents an action from being executed.
|
||||
|
||||
Blockers exist solely to improve UX and reduce unnecessary requests.
|
||||
|
||||
Blockers are not security.
|
||||
|
||||
## 3) Responsibility split (hard boundary)
|
||||
|
||||
| Aspect | Blocker (Website) | Guard (API) |
|
||||
|---|---|---|
|
||||
| Purpose | Prevent execution | Enforce rules |
|
||||
| Security | ❌ No | ✅ Yes |
|
||||
| Authority | ❌ Best-effort | ✅ Final |
|
||||
| Reversible | ✅ Yes | ❌ No |
|
||||
| Failure effect | UX feedback | HTTP error |
|
||||
|
||||
## 4) Naming rules (hard)
|
||||
|
||||
- Website uses `*Blocker`.
|
||||
- API uses `*Guard`.
|
||||
- Never mix the terms.
|
||||
- Never implement Guards in the website.
|
||||
- Never implement Blockers in the API.
|
||||
|
||||
## 5) Final rule
|
||||
|
||||
If it must be enforced, it is a Guard.
|
||||
|
||||
If it only prevents UX mistakes, it is a Blocker.
|
||||
|
||||
@@ -1,468 +1,47 @@
|
||||
Clean Architecture – Application Services, Use Cases, Ports, and Data Flow (Strict, Final)
|
||||
# Clean Architecture Data Flow (Shared Contract)
|
||||
|
||||
This document defines the final, non-ambiguous Clean Architecture setup for the project.
|
||||
This document defines the **shared** data-flow rules that apply across all delivery applications.
|
||||
|
||||
It explicitly covers:
|
||||
• Use Cases vs Application Services
|
||||
• Input & Output Ports (and what does not exist)
|
||||
• API responsibilities
|
||||
• Frontend responsibilities
|
||||
• Naming, placement, and dependency rules
|
||||
• End-to-end flow with concrete paths and code examples
|
||||
It does not contain app-specific rules.
|
||||
|
||||
There are no hybrid concepts, no overloaded terms, and no optional interpretations.
|
||||
App-specific contracts:
|
||||
|
||||
⸻
|
||||
- Core: [`docs/architecture/core/CORE_DATA_FLOW.md`](docs/architecture/core/CORE_DATA_FLOW.md:1)
|
||||
- API: [`docs/architecture/api/API_DATA_FLOW.md`](docs/architecture/api/API_DATA_FLOW.md:1)
|
||||
- Website: [`docs/architecture/website/WEBSITE_DATA_FLOW.md`](docs/architecture/website/WEBSITE_DATA_FLOW.md:1)
|
||||
|
||||
1. Architectural Layers (Final)
|
||||
## 1) Dependency rule (non-negotiable)
|
||||
|
||||
Domain → Business truth
|
||||
Application → Use Cases + Application Services
|
||||
Adapters → API, Persistence, External Systems
|
||||
Frontend → UI, View Models, UX logic
|
||||
Dependencies point inward.
|
||||
|
||||
Only dependency-inward is allowed.
|
||||
```text
|
||||
Delivery apps → adapters → core
|
||||
```
|
||||
|
||||
⸻
|
||||
Core never depends on delivery apps.
|
||||
|
||||
2. Domain Layer (Core / Domain)
|
||||
## 2) Cross-boundary mapping rule
|
||||
|
||||
What lives here
|
||||
• Entities (classes)
|
||||
• Value Objects (classes)
|
||||
• Domain Services (stateless business logic)
|
||||
• Domain Events
|
||||
• Domain Errors / Invariants
|
||||
|
||||
What NEVER lives here
|
||||
• DTOs
|
||||
• Models
|
||||
• Ports
|
||||
• Use Cases
|
||||
• Application Services
|
||||
• Framework code
|
||||
|
||||
⸻
|
||||
|
||||
3. Application Layer (Core / Application)
|
||||
|
||||
The Application Layer has two distinct responsibilities:
|
||||
1. Use Cases – business decisions
|
||||
2. Application Services – orchestration of multiple use cases
|
||||
|
||||
⸻
|
||||
|
||||
4. Use Cases (Application / Use Cases)
|
||||
|
||||
Definition
|
||||
|
||||
A Use Case represents one business intent.
|
||||
If data crosses a boundary, it is mapped.
|
||||
|
||||
Examples:
|
||||
• CreateLeague
|
||||
• ApproveSponsorship
|
||||
• CompleteDriverOnboarding
|
||||
|
||||
Rules
|
||||
• A Use Case:
|
||||
• contains business logic
|
||||
• enforces invariants
|
||||
• operates on domain entities
|
||||
• communicates ONLY via ports
|
||||
• A Use Case:
|
||||
• does NOT orchestrate multiple workflows
|
||||
• does NOT know HTTP, UI, DB, queues
|
||||
- HTTP Request DTO is mapped to Core input.
|
||||
- Core result is mapped to HTTP Response DTO.
|
||||
|
||||
Structure
|
||||
## 3) Ownership rule
|
||||
|
||||
core/racing/application/use-cases/
|
||||
└─ CreateLeagueUseCase.ts
|
||||
Each layer owns its data shapes.
|
||||
|
||||
Example
|
||||
- Core owns domain and application models.
|
||||
- API owns HTTP DTOs.
|
||||
- Website owns ViewData and ViewModels.
|
||||
|
||||
export class CreateLeagueUseCase {
|
||||
constructor(
|
||||
private readonly leagueRepository: LeagueRepositoryPort,
|
||||
private readonly output: CreateLeagueOutputPort
|
||||
) {}
|
||||
No layer re-exports another layer’s models as-is across a boundary.
|
||||
|
||||
execute(input: CreateLeagueInputPort): void {
|
||||
// business rules & invariants
|
||||
## 4) Non-negotiable rules
|
||||
|
||||
const league = League.create(input.name, input.maxMembers);
|
||||
this.leagueRepository.save(league);
|
||||
1. Core contains business truth.
|
||||
2. Delivery apps translate and enforce.
|
||||
3. Adapters implement ports.
|
||||
|
||||
this.output.presentSuccess(league.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
5. Ports (Application / Ports)
|
||||
|
||||
The Only Two Kinds of Ports
|
||||
|
||||
Everything crossing the Application boundary is a Port.
|
||||
|
||||
Input Ports
|
||||
|
||||
Input Ports describe what a use case needs.
|
||||
|
||||
export interface CreateLeagueInputPort {
|
||||
readonly name: string;
|
||||
readonly maxMembers: number;
|
||||
}
|
||||
|
||||
Rules:
|
||||
• Interfaces only
|
||||
• No behavior
|
||||
• No validation logic
|
||||
|
||||
⸻
|
||||
|
||||
Output Ports
|
||||
|
||||
Output Ports describe how a use case emits outcomes.
|
||||
|
||||
export interface CreateLeagueOutputPort {
|
||||
presentSuccess(leagueId: string): void;
|
||||
presentFailure(reason: string): void;
|
||||
}
|
||||
|
||||
Rules:
|
||||
• No return values
|
||||
• No getters
|
||||
• No state
|
||||
• Use methods, not result objects
|
||||
|
||||
⸻
|
||||
|
||||
6. Application Services (Application / Services)
|
||||
|
||||
Definition
|
||||
|
||||
An Application Service orchestrates multiple Use Cases.
|
||||
|
||||
It exists because:
|
||||
• No single Use Case should know the whole workflow
|
||||
• Orchestration is not business logic
|
||||
|
||||
Rules
|
||||
• Application Services:
|
||||
• call multiple Use Cases
|
||||
• define execution order
|
||||
• handle partial failure & compensation
|
||||
• Application Services:
|
||||
• do NOT contain business rules
|
||||
• do NOT modify entities directly
|
||||
|
||||
Structure
|
||||
|
||||
core/racing/application/services/
|
||||
└─ LeagueSetupService.ts
|
||||
|
||||
Example (with Edge Cases)
|
||||
|
||||
export class LeagueSetupService {
|
||||
constructor(
|
||||
private readonly createLeague: CreateLeagueUseCase,
|
||||
private readonly createSeason: CreateSeasonUseCase,
|
||||
private readonly assignOwner: AssignLeagueOwnerUseCase,
|
||||
private readonly notify: SendLeagueWelcomeNotificationUseCase
|
||||
) {}
|
||||
|
||||
execute(input: LeagueSetupInputPort): void {
|
||||
const leagueId = this.createLeague.execute(input);
|
||||
|
||||
try {
|
||||
this.createSeason.execute({ leagueId });
|
||||
this.assignOwner.execute({ leagueId, ownerId: input.ownerId });
|
||||
this.notify.execute({ leagueId, ownerId: input.ownerId });
|
||||
} catch (error) {
|
||||
// compensation / rollback logic
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Edge cases that belong ONLY here:
|
||||
• Partial failure handling
|
||||
• Workflow order
|
||||
• Optional steps
|
||||
• Retry / idempotency logic
|
||||
|
||||
⸻
|
||||
|
||||
7. API Layer (apps/api)
|
||||
|
||||
Responsibilities
|
||||
• Transport (HTTP)
|
||||
• Validation (request shape)
|
||||
• Mapping to Input Ports
|
||||
• Calling Application Services
|
||||
• Adapting Output Ports
|
||||
|
||||
Structure
|
||||
|
||||
apps/api/leagues/
|
||||
├─ LeagueController.ts
|
||||
├─ presenters/
|
||||
│ └─ CreateLeaguePresenter.ts
|
||||
└─ dto/
|
||||
├─ CreateLeagueRequestDto.ts
|
||||
└─ CreateLeagueResponseDto.ts
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
API Presenter (Adapter)
|
||||
|
||||
export class CreateLeaguePresenter implements CreateLeagueOutputPort {
|
||||
private response!: CreateLeagueResponseDto;
|
||||
|
||||
presentSuccess(leagueId: string): void {
|
||||
this.response = { success: true, leagueId };
|
||||
}
|
||||
|
||||
presentFailure(reason: string): void {
|
||||
this.response = { success: false, errorMessage: reason };
|
||||
}
|
||||
|
||||
getResponse(): CreateLeagueResponseDto {
|
||||
return this.response;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
8. Frontend Layer (apps/website)
|
||||
|
||||
The frontend layer contains UI-specific data shapes. None of these cross into the Core.
|
||||
|
||||
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 (client-only presentation classes)
|
||||
4. ViewData (template input, serializable)
|
||||
|
||||
⸻
|
||||
|
||||
8.1 API DTOs (Transport Contracts)
|
||||
|
||||
API DTOs represent exact HTTP contracts exposed by the backend.
|
||||
They are usually generated from OpenAPI or manually mirrored.
|
||||
|
||||
apps/website/lib/dtos/
|
||||
└─ CreateLeagueResponseDto.ts
|
||||
|
||||
Rules:
|
||||
• Exact mirror of backend response
|
||||
• No UI logic
|
||||
• No derived values
|
||||
• Never used directly by components
|
||||
|
||||
⸻
|
||||
|
||||
8.2 Command Models (User Input / Form State)
|
||||
|
||||
Command Models represent user intent before submission.
|
||||
They are frontend-only and exist to manage:
|
||||
• form state
|
||||
• validation feedback
|
||||
• step-based wizards
|
||||
|
||||
They are NOT:
|
||||
• domain objects
|
||||
• API DTOs
|
||||
• View Models
|
||||
|
||||
apps/website/lib/commands/
|
||||
└─ CreateLeagueCommandModel.ts
|
||||
|
||||
Rules:
|
||||
• Classes (stateful)
|
||||
• May contain client-side validation
|
||||
• May contain UX-specific helpers (step validation, dirty flags)
|
||||
• Must expose a method to convert to an API Request DTO
|
||||
|
||||
Example responsibility:
|
||||
• hold incomplete or invalid user input
|
||||
• guide the user through multi-step flows
|
||||
• prepare data for submission
|
||||
|
||||
Command Models:
|
||||
• are consumed by components
|
||||
• are passed into services
|
||||
• are never sent directly over HTTP
|
||||
|
||||
⸻
|
||||
|
||||
8.3 View Models (UI Display State)
|
||||
|
||||
View Models represent fully prepared UI state after data is loaded.
|
||||
|
||||
apps/website/lib/view-models/
|
||||
└─ CreateLeagueViewModel.ts
|
||||
|
||||
Rules:
|
||||
• Classes only
|
||||
• UI logic allowed (formatting, labels, derived flags)
|
||||
• 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)
|
||||
|
||||
Website Presenters are pure mappers.
|
||||
|
||||
export class CreateLeaguePresenter {
|
||||
present(dto: CreateLeagueResponseDto): CreateLeagueViewModel {
|
||||
return new CreateLeagueViewModel(dto);
|
||||
}
|
||||
}
|
||||
|
||||
Rules:
|
||||
• Input: API DTOs
|
||||
• Output: View Models
|
||||
• No side effects
|
||||
• No API calls
|
||||
|
||||
⸻
|
||||
|
||||
8.5 Website Services (Orchestration)
|
||||
|
||||
Website Services orchestrate:
|
||||
• Command Models
|
||||
• API Client calls
|
||||
• Presenter mappings
|
||||
|
||||
export class LeagueService {
|
||||
async createLeague(command: CreateLeagueCommandModel): Promise<CreateLeagueViewModel> {
|
||||
const dto = await this.api.createLeague(command.toRequestDto());
|
||||
return this.presenter.present(dto);
|
||||
}
|
||||
}
|
||||
|
||||
Rules:
|
||||
• Services accept Command Models
|
||||
• Services return View Models
|
||||
• Components never call API clients directly
|
||||
|
||||
⸻
|
||||
|
||||
View Models (UI State)
|
||||
|
||||
apps/website/lib/view-models/
|
||||
└─ CreateLeagueViewModel.ts
|
||||
|
||||
export class CreateLeagueViewModel {
|
||||
constructor(private readonly dto: CreateLeagueResponseDto) {}
|
||||
|
||||
get message(): string {
|
||||
return this.dto.success
|
||||
? 'League created successfully'
|
||||
: this.dto.errorMessage ?? 'Creation failed';
|
||||
}
|
||||
}
|
||||
|
||||
Rules:
|
||||
• Classes only
|
||||
• UI logic allowed
|
||||
• No domain logic
|
||||
|
||||
⸻
|
||||
|
||||
Website Presenter (DTO → ViewModel)
|
||||
|
||||
export class CreateLeaguePresenter {
|
||||
present(dto: CreateLeagueResponseDto): CreateLeagueViewModel {
|
||||
return new CreateLeagueViewModel(dto);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
Website Service (Orchestration)
|
||||
|
||||
export class LeagueService {
|
||||
constructor(
|
||||
private readonly api: LeaguesApiClient,
|
||||
private readonly presenter: CreateLeaguePresenter
|
||||
) {}
|
||||
|
||||
async createLeague(input: unknown): Promise<CreateLeagueViewModel> {
|
||||
const dto = await this.api.createLeague(input);
|
||||
return this.presenter.present(dto);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
9. Full End-to-End Flow (Final)
|
||||
|
||||
UI Component
|
||||
→ Website Service
|
||||
→ API Client
|
||||
→ HTTP Request DTO
|
||||
→ API Controller
|
||||
→ Application Service
|
||||
→ Use Case(s)
|
||||
→ Domain
|
||||
→ Output Port
|
||||
→ API Presenter
|
||||
→ HTTP Response DTO
|
||||
→ Website Presenter
|
||||
→ View Model
|
||||
→ UI
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
10. Final Non-Negotiable Rules
|
||||
• Core knows ONLY Ports + Domain
|
||||
• Core has NO Models, DTOs, or ViewModels
|
||||
• API talks ONLY to Application Services
|
||||
• Controllers NEVER call Use Cases directly
|
||||
• Frontend Components see ONLY ViewData (Templates) or ViewModels (Client orchestrators)
|
||||
• API DTOs never cross into Templates
|
||||
• View Models never cross into Templates
|
||||
|
||||
⸻
|
||||
|
||||
11. Final Merksatz
|
||||
|
||||
Use Cases decide.
|
||||
Application Services orchestrate.
|
||||
Adapters translate.
|
||||
UI presents.
|
||||
|
||||
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)
|
||||
|
||||
84
docs/architecture/shared/ENUMS.md
Normal file
84
docs/architecture/shared/ENUMS.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Enums (Shared Contract)
|
||||
|
||||
This document defines how enums are modeled, placed, and used across the system.
|
||||
|
||||
Enums are frequently a source of architectural leakage. This contract removes ambiguity.
|
||||
|
||||
## 1) Core principle (non-negotiable)
|
||||
|
||||
Enums represent knowledge.
|
||||
|
||||
Knowledge must live where it is true.
|
||||
|
||||
## 2) Enum categories (strict)
|
||||
|
||||
There are four and only four valid enum categories:
|
||||
|
||||
1. Domain enums
|
||||
2. Application workflow enums
|
||||
3. Transport enums (HTTP contracts)
|
||||
4. UI enums (website-only)
|
||||
|
||||
## 3) Domain enums
|
||||
|
||||
Definition:
|
||||
|
||||
- business meaning
|
||||
- affects rules or invariants
|
||||
|
||||
Placement:
|
||||
|
||||
- `core/**`
|
||||
|
||||
Rule:
|
||||
|
||||
- domain enums MUST NOT cross a delivery boundary
|
||||
|
||||
## 4) Application workflow enums
|
||||
|
||||
Definition:
|
||||
|
||||
- internal workflow coordination
|
||||
- not business truth
|
||||
|
||||
Placement:
|
||||
|
||||
- `core/**`
|
||||
|
||||
Rule:
|
||||
|
||||
- workflow enums MUST remain internal
|
||||
|
||||
## 5) Transport enums
|
||||
|
||||
Definition:
|
||||
|
||||
- constrain HTTP contracts
|
||||
|
||||
Placement:
|
||||
|
||||
- `apps/api/**` and `apps/website/**` (as transport representations)
|
||||
|
||||
Rules:
|
||||
|
||||
- transport enums are copies, not reexports of domain enums
|
||||
- transport enums MUST NOT be used inside Core
|
||||
|
||||
## 6) UI enums
|
||||
|
||||
Definition:
|
||||
|
||||
- website presentation or interaction state
|
||||
|
||||
Placement:
|
||||
|
||||
- `apps/website/**`
|
||||
|
||||
Rule:
|
||||
|
||||
- UI enums MUST NOT leak into API or Core
|
||||
|
||||
## 7) Final rule
|
||||
|
||||
If an enum crosses a boundary, it is in the wrong place.
|
||||
|
||||
@@ -1,315 +1,37 @@
|
||||
# Feature Availability (Modes + Feature Flags)
|
||||
# Feature Availability (Shared Contract)
|
||||
|
||||
This document defines a clean, consistent system for enabling/disabling functionality across:
|
||||
- API endpoints
|
||||
- Website links/navigation
|
||||
- Website components
|
||||
This document defines the shared, cross-app system for enabling and disabling capabilities.
|
||||
|
||||
It is designed to support:
|
||||
- test mode
|
||||
- maintenance mode
|
||||
- disabling features due to risk/issues
|
||||
- coming soon features
|
||||
- future super admin flag management
|
||||
Feature availability is not authorization.
|
||||
|
||||
It is aligned with the hard separation of responsibilities in `Blockers & Guards`:
|
||||
- Frontend uses Blockers (UX best-effort)
|
||||
- Backend uses Guards (authoritative enforcement)
|
||||
Shared contract:
|
||||
|
||||
See: docs/architecture/BLOCKER_GUARDS.md
|
||||
- Blockers and Guards: [`docs/architecture/shared/BLOCKERS_AND_GUARDS.md`](docs/architecture/shared/BLOCKERS_AND_GUARDS.md:1)
|
||||
|
||||
---
|
||||
|
||||
## 1) Core Principle
|
||||
## 1) Core principle (non-negotiable)
|
||||
|
||||
Availability is decided once, then applied in multiple places.
|
||||
|
||||
- Backend Guards enforce availability for correctness and security.
|
||||
- Frontend Blockers reflect availability for UX, but must never be relied on for enforcement.
|
||||
- API Guards enforce availability.
|
||||
- Website Blockers reflect availability for UX.
|
||||
|
||||
If it must be enforced, it is a Guard.
|
||||
If it only improves UX, it is a Blocker.
|
||||
## 2) Capability model (strict)
|
||||
|
||||
---
|
||||
Inputs to evaluation:
|
||||
|
||||
## 2) Definitions (Canonical Vocabulary)
|
||||
|
||||
### 2.1 Operational Mode (system-level)
|
||||
A small, global state representing operational posture.
|
||||
|
||||
Recommended enum:
|
||||
- normal
|
||||
- maintenance
|
||||
- test
|
||||
|
||||
Operational Mode is:
|
||||
- authoritative in backend
|
||||
- typically environment-scoped
|
||||
- required for rapid response (maintenance must be runtime-changeable)
|
||||
|
||||
### 2.2 Feature State (capability-level)
|
||||
A per-feature state machine (not a boolean).
|
||||
|
||||
Recommended enum:
|
||||
- enabled
|
||||
- disabled
|
||||
- coming_soon
|
||||
- hidden
|
||||
|
||||
Semantics:
|
||||
- enabled: feature is available and advertised
|
||||
- disabled: feature exists but must not be used (safety kill switch)
|
||||
- coming_soon: may be visible in UI as teaser, but actions are blocked
|
||||
- hidden: not visible/advertised; actions are blocked (safest default)
|
||||
|
||||
### 2.3 Capability
|
||||
A named unit of functionality (stable key) used consistently across API + website.
|
||||
|
||||
Examples:
|
||||
- races.create
|
||||
- payments.checkout
|
||||
- sponsor.portal
|
||||
- stewarding.protests
|
||||
|
||||
A capability key is a contract.
|
||||
|
||||
### 2.4 Action Type
|
||||
Availability decisions vary by the type of action:
|
||||
- view: read-only operations (pages, GET endpoints)
|
||||
- mutate: state-changing operations (POST/PUT/PATCH/DELETE)
|
||||
|
||||
---
|
||||
|
||||
## 3) Policy Model (What Exists)
|
||||
|
||||
### 3.1 FeatureAvailabilityPolicy (single evaluation model)
|
||||
One evaluation function produces a decision.
|
||||
|
||||
Inputs:
|
||||
- environment (dev/test/prod)
|
||||
- operationalMode (normal/maintenance/test)
|
||||
- capabilityKey (string)
|
||||
- actionType (view/mutate)
|
||||
- actorContext (anonymous/authenticated; roles later)
|
||||
- operational mode (normal, maintenance, test)
|
||||
- capability key (stable string)
|
||||
- action type (view, mutate)
|
||||
- actor context (anonymous, authenticated)
|
||||
|
||||
Outputs:
|
||||
- allow: boolean
|
||||
- publicReason: one of maintenance | disabled | coming_soon | hidden | not_configured
|
||||
- uxHint: optional { messageKey, redirectPath, showTeaser }
|
||||
|
||||
The same decision model is reused by:
|
||||
- API Guard enforcement
|
||||
- Website navigation visibility
|
||||
- Website component rendering/disablement
|
||||
- allow or deny
|
||||
- a public reason (maintenance, disabled, coming_soon, hidden, not_configured)
|
||||
|
||||
### 3.2 Precedence (where values come from)
|
||||
To avoid “mystery behavior”, use strict precedence:
|
||||
## 3) Non-negotiable rules
|
||||
|
||||
1. runtime overrides (highest priority)
|
||||
2. build-time environment configuration
|
||||
3. code defaults (lowest priority, should be safe: hidden/disabled)
|
||||
1. Default is deny unless explicitly enabled.
|
||||
2. The API is authoritative.
|
||||
3. The website is UX-only.
|
||||
|
||||
Rationale:
|
||||
- runtime overrides enable emergency response without rebuild
|
||||
- env config enables environment-specific defaults
|
||||
- code defaults keep behavior deterministic if config is missing
|
||||
|
||||
---
|
||||
|
||||
## 4) Evaluation Rules (Deterministic, Explicit)
|
||||
|
||||
### 4.1 Maintenance mode rules
|
||||
Maintenance must be able to block the platform fast and consistently.
|
||||
|
||||
Default behavior:
|
||||
- mutate actions: denied unless explicitly allowlisted
|
||||
- view actions: allowed only for a small allowlist (status page, login, health, static public routes)
|
||||
|
||||
This creates a safe “fail closed” posture.
|
||||
|
||||
Optional refinement:
|
||||
- define a maintenance allowlist for critical reads (e.g., dashboards for operators)
|
||||
|
||||
### 4.2 Test mode rules
|
||||
Test mode should primarily exist in non-prod, and should be explicit in prod.
|
||||
|
||||
Recommended behavior:
|
||||
- In prod, test mode should not be enabled accidentally.
|
||||
- In test environments, test mode may:
|
||||
- enable test-only endpoints
|
||||
- bypass external integrations (through adapters)
|
||||
- relax rate limits
|
||||
- expose test banners in UI (Blocker-level display)
|
||||
|
||||
### 4.3 Feature state rules (per capability)
|
||||
Given a capability state:
|
||||
|
||||
- enabled:
|
||||
- allow view + mutate (subject to auth/roles)
|
||||
- visible in UI
|
||||
- coming_soon:
|
||||
- allow view of teaser pages/components
|
||||
- deny mutate and deny sensitive reads
|
||||
- visible in UI with Coming Soon affordances
|
||||
- disabled:
|
||||
- deny view + mutate
|
||||
- hidden in nav by default
|
||||
- hidden:
|
||||
- deny view + mutate
|
||||
- never visible in UI
|
||||
|
||||
Note:
|
||||
- “disabled” and “hidden” are both blocked; the difference is UI and information disclosure.
|
||||
|
||||
### 4.4 Missing configuration
|
||||
If a capability is not configured:
|
||||
- treat as hidden (fail closed)
|
||||
- optionally log a warning (server-side)
|
||||
|
||||
---
|
||||
|
||||
## 5) Enforcement Mapping (Where Each Requirement Lives)
|
||||
|
||||
This section is the “wiring contract” across layers.
|
||||
|
||||
### 5.1 API endpoints (authoritative)
|
||||
- Enforce via Backend Guards (NestJS CanActivate).
|
||||
- Endpoints must declare the capability they require.
|
||||
|
||||
Mapping to HTTP:
|
||||
- maintenance: 503 Service Unavailable (preferred for global maintenance)
|
||||
- disabled/hidden: 404 Not Found (avoid advertising unavailable capabilities)
|
||||
- coming_soon: 404 Not Found publicly, or 409 Conflict internally if you want explicit semantics for trusted clients later
|
||||
|
||||
Guideline:
|
||||
- External clients should not get detailed feature availability information unless explicitly intended.
|
||||
|
||||
### 5.2 Website links / navigation (UX)
|
||||
- Enforce via Frontend Blockers.
|
||||
- Hide links when state is disabled/hidden.
|
||||
- For coming_soon, show link but route to teaser page or disable with explanation.
|
||||
|
||||
Rules:
|
||||
- Never assume hidden in UI equals enforced on server.
|
||||
- UI should degrade gracefully (API may still block).
|
||||
|
||||
### 5.3 Website components (UX)
|
||||
- Use Blockers to:
|
||||
- hide components for hidden/disabled
|
||||
- show teaser content for coming_soon
|
||||
- disable buttons or flows for coming_soon/disabled, with consistent messaging
|
||||
|
||||
Recommendation:
|
||||
- Provide a single reusable component (FeatureBlocker) that consumes policy decisions and renders:
|
||||
- children when allowed
|
||||
- teaser when coming_soon
|
||||
- null or fallback when disabled/hidden
|
||||
|
||||
---
|
||||
|
||||
## 6) Build-Time vs Runtime (Clean, Predictable)
|
||||
|
||||
### 6.1 Build-time flags (require rebuild/redeploy)
|
||||
What they are good for:
|
||||
- preventing unfinished UI code from shipping in a bundle
|
||||
- cutting entire routes/components from builds for deterministic releases
|
||||
|
||||
Limitations:
|
||||
- NEXT_PUBLIC_* values are compiled into the client bundle; changing them does not update clients without rebuild.
|
||||
|
||||
Use build-time flags for:
|
||||
- experimental UI
|
||||
- “not yet shipped” components/routes
|
||||
- simplifying deployments (pre-launch vs alpha style gating)
|
||||
|
||||
### 6.2 Runtime flags (no rebuild)
|
||||
What they are for:
|
||||
- maintenance mode
|
||||
- emergency disable for broken endpoints
|
||||
- quickly hiding risky features
|
||||
|
||||
Runtime flags must be available to:
|
||||
- API Guards (always)
|
||||
- Website SSR/middleware optionally
|
||||
- Website client optionally (for UX only)
|
||||
|
||||
Key tradeoff:
|
||||
- runtime access introduces caching and latency concerns
|
||||
- treat runtime policy reads as cached, fast, and resilient
|
||||
|
||||
Recommended approach:
|
||||
- API is authoritative source of runtime policy
|
||||
- website can optionally consume a cached policy snapshot endpoint
|
||||
|
||||
---
|
||||
|
||||
## 7) Storage and Distribution (Now + Future Super Admin)
|
||||
|
||||
### 7.1 Now (no super admin UI)
|
||||
Use a single “policy snapshot” stored in one place and read by the API, with caching.
|
||||
|
||||
Options (in priority order):
|
||||
1. Remote KV/DB-backed policy snapshot (preferred for true runtime changes)
|
||||
2. Environment variable JSON (simpler, but changes require restart/redeploy)
|
||||
3. Static config file in repo (requires rebuild/redeploy)
|
||||
|
||||
### 7.2 Future (super admin UI)
|
||||
Super admin becomes a writer to the same store.
|
||||
|
||||
Non-negotiable:
|
||||
- The storage schema must be stable and versioned.
|
||||
|
||||
Recommended schema (conceptual):
|
||||
- policyVersion
|
||||
- operationalMode
|
||||
- capabilities: map of capabilityKey -> featureState
|
||||
- allowlists: maintenance view/mutate allowlists
|
||||
- optional targeting rules later (by role/user)
|
||||
|
||||
---
|
||||
|
||||
## 8) Data Flow (Conceptual)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
UI[Website UI] --> FB[Frontend Blockers]
|
||||
FB --> PC[Policy Client]
|
||||
UI --> API[API Request]
|
||||
API --> FG[Feature Guard]
|
||||
FG --> AS[API Application Service]
|
||||
AS --> UC[Core Use Case]
|
||||
PC --> PS[Policy Snapshot]
|
||||
FG --> PS
|
||||
```
|
||||
|
||||
Interpretation:
|
||||
- Website reads policy for UX (best-effort).
|
||||
- API enforces policy (authoritative) before any application logic.
|
||||
|
||||
---
|
||||
|
||||
## 9) Implementation Checklist (For Code Mode)
|
||||
|
||||
Backend (apps/api):
|
||||
- Define capability keys and feature states as shared types in a local module.
|
||||
- Create FeaturePolicyService that resolves the current policy snapshot (cached).
|
||||
- Add FeatureFlagGuard (or FeatureAvailabilityGuard) that:
|
||||
- reads required capability metadata for an endpoint
|
||||
- evaluates allow/deny with actionType
|
||||
- maps denial to the chosen HTTP status codes
|
||||
|
||||
Frontend (apps/website):
|
||||
- Add a small PolicyClient that fetches policy snapshot from API (optional for phase 1).
|
||||
- Add FeatureBlocker component for consistent UI behavior.
|
||||
- Centralize navigation link definitions and filter them via policy.
|
||||
|
||||
Ops/Config:
|
||||
- Define how maintenance mode is toggled (KV/DB entry or config endpoint restricted to operators later).
|
||||
- Ensure defaults are safe (fail closed).
|
||||
|
||||
---
|
||||
|
||||
## 10) Non-Goals (Explicit)
|
||||
- This system is not an authorization system.
|
||||
- Roles/permissions are separate (but can be added as actorContext inputs later).
|
||||
- Blockers never replace Guards.
|
||||
@@ -1,115 +0,0 @@
|
||||
# File Structure
|
||||
|
||||
## Core
|
||||
|
||||
```
|
||||
core/ # * Business- & Anwendungslogik (framework-frei)
|
||||
├── shared/ # * Gemeinsame Core-Bausteine
|
||||
│ ├── domain/ # * Domain-Basistypen
|
||||
│ │ ├── Entity.ts # * Basisklasse für Entities
|
||||
│ │ ├── ValueObject.ts # * Basisklasse für Value Objects
|
||||
│ │ └── DomainError.ts # * Domain-spezifische Fehler
|
||||
│ └── application/
|
||||
│ └── ApplicationError.ts # * Use-Case-/Application-Fehler
|
||||
│
|
||||
├── racing/ # * Beispiel-Domain (Bounded Context)
|
||||
│ ├── domain/ # * Fachliche Wahrheit
|
||||
│ │ ├── entities/ # * Aggregate Roots & Entities
|
||||
│ │ │ ├── League.ts # * Aggregate Root
|
||||
│ │ │ └── Race.ts # * Entity
|
||||
│ │ ├── value-objects/ # * Unveränderliche Fachwerte
|
||||
│ │ │ └── LeagueName.ts # * Beispiel VO
|
||||
│ │ ├── services/ # * Domain Services (Regeln, kein Ablauf)
|
||||
│ │ │ └── ChampionshipScoringService.ts # * Regel über mehrere Entities
|
||||
│ │ └── errors/ # * Domain-Invariantenfehler
|
||||
│ │ └── RacingDomainError.ts
|
||||
│ │
|
||||
│ └── application/ # * Anwendungslogik
|
||||
│ ├── ports/ # * EINZIGE Schnittstellen des Cores
|
||||
│ │ ├── input/ # * Input Ports (Use-Case-Grenzen)
|
||||
│ │ │ └── CreateLeagueInputPort.ts
|
||||
│ │ └── output/ # * Output Ports (Use-Case-Ergebnisse)
|
||||
│ │ └── CreateLeagueOutputPort.ts
|
||||
│ │
|
||||
│ ├── use-cases/ # * Einzelne Business-Intents
|
||||
│ │ └── CreateLeagueUseCase.ts
|
||||
│ │
|
||||
│ └── services/ # * Application Services (Orchestrierung)
|
||||
│ └── LeagueSetupService.ts # * Koordiniert mehrere Use Cases
|
||||
```
|
||||
|
||||
## Adapters
|
||||
|
||||
```
|
||||
adapters/ # * Alle äußeren Implementierungen
|
||||
├── persistence/ # * Datenhaltung
|
||||
│ ├── typeorm/ # Konkrete DB-Technologie
|
||||
│ │ ├── entities/ # ORM-Entities (nicht Domain!)
|
||||
│ │ └── repositories/ # * Implementieren Core-Ports
|
||||
│ │ └── LeagueRepository.ts
|
||||
│ └── inmemory/ # Test-/Dev-Implementierungen
|
||||
│ └── LeagueRepository.ts
|
||||
│
|
||||
├── notifications/ # Externe Systeme
|
||||
│ └── EmailNotificationAdapter.ts # Implementiert Notification-Port
|
||||
│
|
||||
├── logging/ # Logging / Telemetrie
|
||||
│ └── ConsoleLoggerAdapter.ts # Adapter für Logger-Port
|
||||
│
|
||||
└── bootstrap/ # Initialisierung / Seeding
|
||||
└── EnsureInitialData.ts # App-Start-Logik
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
```
|
||||
apps/api/ # * Delivery Layer (HTTP)
|
||||
├── app.module.ts # * Framework-Zusammenbau
|
||||
│
|
||||
├── leagues/ # * Feature-Modul
|
||||
│ ├── LeagueController.ts # * HTTP → Application Service
|
||||
│ │
|
||||
│ ├── dto/ # * Transport-DTOs (HTTP)
|
||||
│ │ ├── CreateLeagueRequestDto.ts # * Request-Shape
|
||||
│ │ └── CreateLeagueResponseDto.ts # * Response-Shape
|
||||
│ │
|
||||
│ └── presenters/ # * Output-Port-Adapter
|
||||
│ └── CreateLeaguePresenter.ts # * Core Output → HTTP Response
|
||||
│
|
||||
└── shared/ # API-spezifisch
|
||||
└── filters/ # Exception-Handling
|
||||
```
|
||||
|
||||
## Frontend
|
||||
```
|
||||
apps/website/ # * Frontend (UI)
|
||||
├── app/ # * Next.js Routen
|
||||
│ └── leagues/ # * Page-Level
|
||||
│ └── page.tsx
|
||||
│
|
||||
├── components/ # * Reine UI-Komponenten
|
||||
│ └── LeagueForm.tsx
|
||||
│
|
||||
├── lib/
|
||||
│ ├── api/ # * HTTP-Client
|
||||
│ │ └── LeaguesApiClient.ts # * Gibt NUR API DTOs zurück
|
||||
│ │
|
||||
│ ├── dtos/ # * API-Vertrags-Typen
|
||||
│ │ └── CreateLeagueResponseDto.ts
|
||||
│ │
|
||||
│ ├── commands/ # * Command Models (Form State)
|
||||
│ │ └── CreateLeagueCommandModel.ts
|
||||
│ │
|
||||
│ ├── presenters/ # * DTO → ViewModel Mapper
|
||||
│ │ └── CreateLeaguePresenter.ts
|
||||
│ │
|
||||
│ ├── view-models/ # * UI-State
|
||||
│ │ └── CreateLeagueViewModel.ts
|
||||
│ │
|
||||
│ ├── services/ # * Frontend-Orchestrierung
|
||||
│ │ └── LeagueService.ts
|
||||
│ │
|
||||
│ └── blockers/ # UX-Schutz (Throttle, Submit)
|
||||
│ ├── SubmitBlocker.ts
|
||||
│ └── ThrottleBlocker.ts
|
||||
```
|
||||
56
docs/architecture/shared/REPOSITORY_STRUCTURE.md
Normal file
56
docs/architecture/shared/REPOSITORY_STRUCTURE.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Repository Structure (Shared Contract)
|
||||
|
||||
This document defines the **physical repository structure**.
|
||||
|
||||
It describes **where** code lives, not what responsibilities are.
|
||||
|
||||
## 1) Top-level layout (strict)
|
||||
|
||||
```text
|
||||
core/ business logic (framework-free)
|
||||
adapters/ reusable infrastructure implementations
|
||||
apps/ delivery applications (API, website)
|
||||
docs/ documentation
|
||||
tests/ cross-app tests
|
||||
```
|
||||
|
||||
## 2) Meaning of each top-level folder
|
||||
|
||||
### 2.1 `core/`
|
||||
|
||||
The Core contains domain and application logic.
|
||||
|
||||
See [`docs/architecture/core/CORE_FILE_STRUCTURE.md`](docs/architecture/core/CORE_FILE_STRUCTURE.md:1).
|
||||
|
||||
### 2.2 `adapters/`
|
||||
|
||||
Adapters are **reusable outer-layer implementations**.
|
||||
|
||||
Rules:
|
||||
|
||||
- adapters implement Core ports
|
||||
- adapters contain technical details (DB, external systems)
|
||||
- adapters do not define HTTP routes
|
||||
|
||||
See [`docs/architecture/shared/ADAPTERS.md`](docs/architecture/shared/ADAPTERS.md:1).
|
||||
|
||||
### 2.3 `apps/`
|
||||
|
||||
Apps are **delivery mechanisms**.
|
||||
|
||||
This repo has (at minimum):
|
||||
|
||||
- `apps/api` (HTTP API)
|
||||
- `apps/website` (Next.js website)
|
||||
|
||||
See:
|
||||
|
||||
- [`docs/architecture/api/API_FILE_STRUCTURE.md`](docs/architecture/api/API_FILE_STRUCTURE.md:1)
|
||||
- [`docs/architecture/website/WEBSITE_FILE_STRUCTURE.md`](docs/architecture/website/WEBSITE_FILE_STRUCTURE.md:1)
|
||||
|
||||
## 3) Non-negotiable rules
|
||||
|
||||
1. Business truth lives in `core/`.
|
||||
2. `apps/*` are delivery apps; they translate and enforce.
|
||||
3. `adapters/*` implement ports and contain technical details.
|
||||
|
||||
@@ -1,640 +0,0 @@
|
||||
# Unified Authentication & Authorization Architecture
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document defines a **clean, predictable, and secure** authentication and authorization architecture that eliminates the current "fucking unpredictable mess" by establishing clear boundaries between server-side and client-side responsibilities.
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### What's Wrong
|
||||
|
||||
1. **Confusing Layers**: Middleware, RouteGuards, AuthGuards, Blockers, Gateways - unclear hierarchy
|
||||
2. **Mixed Responsibilities**: Server and client both doing similar checks inconsistently
|
||||
3. **Inconsistent Patterns**: Some routes use middleware, some use guards, some use both
|
||||
4. **Role Confusion**: Frontend has role logic that should be server-only
|
||||
5. **Debugging Nightmare**: Multiple layers with unclear flow
|
||||
|
||||
### What's Actually Working
|
||||
|
||||
1. **API Guards**: Clean NestJS pattern with `@Public()`, `@RequireRoles()`
|
||||
2. **Basic Middleware**: Route protection works at edge
|
||||
3. **Auth Context**: Session management exists
|
||||
4. **Permission Model**: Documented in AUTHORIZATION.md
|
||||
|
||||
## Core Principle: Server as Source of Truth
|
||||
|
||||
**Golden Rule**: The API server is the **single source of truth** for authentication and authorization. The client is a dumb terminal that displays what the server allows.
|
||||
|
||||
### Server-Side Responsibilities (API)
|
||||
|
||||
#### 1. Authentication
|
||||
- ✅ **Session Validation**: Verify JWT/session cookies
|
||||
- ✅ **Identity Resolution**: Who is this user?
|
||||
- ✅ **Token Management**: Issue, refresh, revoke tokens
|
||||
- ❌ **UI Redirects**: Never redirect, return 401/403
|
||||
|
||||
#### 2. Authorization
|
||||
- ✅ **Role Verification**: Check user roles against requirements
|
||||
- ✅ **Permission Evaluation**: Check capabilities (view/mutate)
|
||||
- ✅ **Scope Resolution**: Determine league/sponsor/team context
|
||||
- ✅ **Access Denial**: Return 401/403 with clear messages
|
||||
- ❌ **Client State**: Never trust client-provided identity
|
||||
|
||||
#### 3. Data Filtering
|
||||
- ✅ **Filter sensitive data**: Remove fields based on permissions
|
||||
- ✅ **Scope-based queries**: Only return data user can access
|
||||
- ❌ **Client-side filtering**: Never rely on frontend to hide data
|
||||
|
||||
### Client-Side Responsibilities (Website)
|
||||
|
||||
#### 1. UX Enhancement
|
||||
- ✅ **Loading States**: Show "Verifying authentication..."
|
||||
- ✅ **Redirects**: Send unauthenticated users to login
|
||||
- ✅ **UI Hiding**: Hide buttons/links user can't access
|
||||
- ✅ **Feedback**: Show "Access denied" messages
|
||||
- ❌ **Security**: Never trust client checks for security
|
||||
|
||||
#### 2. Session Management
|
||||
- ✅ **Session Cache**: Store session in context
|
||||
- ✅ **Auto-refresh**: Fetch session on app load
|
||||
- ✅ **Logout Flow**: Clear local state, call API logout
|
||||
- ❌ **Role Logic**: Don't make decisions based on roles
|
||||
|
||||
#### 3. Route Protection
|
||||
- ✅ **Middleware**: Basic auth check at edge
|
||||
- ✅ **Layout Guards**: Verify session before rendering
|
||||
- ✅ **Page Guards**: Additional verification (defense in depth)
|
||||
- ❌ **Authorization**: Don't check permissions, let API fail
|
||||
|
||||
## Clean Architecture Layers
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ USER REQUEST │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 1. EDGE MIDDLEWARE (Next.js) │
|
||||
│ • Check for session cookie │
|
||||
│ • Public routes: Allow through │
|
||||
│ • Protected routes: Require auth cookie │
|
||||
│ • Redirect to login if no cookie │
|
||||
│ • NEVER check roles here │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 2. API REQUEST (with session cookie) │
|
||||
│ • NestJS AuthenticationGuard extracts user from session │
|
||||
│ • Attaches user identity to request │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 3. API AUTHORIZATION GUARD │
|
||||
│ • Check route metadata: @Public(), @RequireRoles() │
|
||||
│ • Evaluate permissions based on user identity │
|
||||
│ • Return 401 (unauthenticated) or 403 (forbidden) │
|
||||
│ • NEVER redirect, NEVER trust client identity │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 4. API CONTROLLER │
|
||||
│ • Execute business logic │
|
||||
│ • Filter data based on permissions │
|
||||
│ • Return appropriate response │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 5. CLIENT RESPONSE HANDLING │
|
||||
│ • 200: Render data │
|
||||
│ • 401: Redirect to login with returnTo │
|
||||
│ • 403: Show "Access denied" message │
|
||||
│ • 404: Show "Not found" (for non-disclosure) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 6. COMPONENT RENDERING │
|
||||
│ • Layout guards: Verify session exists │
|
||||
│ • Route guards: Show loading → content or redirect │
|
||||
│ • UI elements: Hide buttons user can't use │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Implementation: Clean Route Protection
|
||||
|
||||
### Step 1: Simplify Middleware (Edge Layer)
|
||||
|
||||
**File**: `apps/website/middleware.ts`
|
||||
|
||||
```typescript
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
/**
|
||||
* Edge Middleware - Simple and Predictable
|
||||
*
|
||||
* Responsibilities:
|
||||
* 1. Allow public routes (static assets, auth pages, discovery)
|
||||
* 2. Check for session cookie on protected routes
|
||||
* 3. Redirect to login if no cookie
|
||||
* 4. Let everything else through (API handles authorization)
|
||||
*/
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// 1. Always allow static assets and API routes
|
||||
if (
|
||||
pathname.startsWith('/_next/') ||
|
||||
pathname.startsWith('/api/') ||
|
||||
pathname.match(/\.(svg|png|jpg|jpeg|gif|webp|ico|css|js)$/)
|
||||
) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// 2. Define public routes (no auth required)
|
||||
const publicRoutes = [
|
||||
'/',
|
||||
'/auth/login',
|
||||
'/auth/signup',
|
||||
'/auth/forgot-password',
|
||||
'/auth/reset-password',
|
||||
'/auth/iracing',
|
||||
'/auth/iracing/start',
|
||||
'/auth/iracing/callback',
|
||||
'/leagues',
|
||||
'/drivers',
|
||||
'/teams',
|
||||
'/leaderboards',
|
||||
'/races',
|
||||
'/sponsor/signup',
|
||||
];
|
||||
|
||||
// 3. Check if current route is public
|
||||
const isPublic = publicRoutes.includes(pathname) ||
|
||||
publicRoutes.some(route => pathname.startsWith(route + '/'));
|
||||
|
||||
if (isPublic) {
|
||||
// Special handling: redirect authenticated users away from auth pages
|
||||
const hasAuthCookie = request.cookies.has('gp_session');
|
||||
const authRoutes = ['/auth/login', '/auth/signup', '/auth/forgot-password', '/auth/reset-password'];
|
||||
|
||||
if (authRoutes.includes(pathname) && hasAuthCookie) {
|
||||
return NextResponse.redirect(new URL('/dashboard', request.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// 4. Protected routes: require session cookie
|
||||
const hasAuthCookie = request.cookies.has('gp_session');
|
||||
|
||||
if (!hasAuthCookie) {
|
||||
const loginUrl = new URL('/auth/login', request.url);
|
||||
loginUrl.searchParams.set('returnTo', pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
// 5. User has cookie, let them through
|
||||
// API will handle actual authorization
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!_next/static|_next/image|_next/data|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|mp4|webm|mov|avi)$).*)',
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
### Step 2: Clean Layout Guards (Client Layer)
|
||||
|
||||
**File**: `apps/website/lib/guards/AuthLayout.tsx`
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { LoadingState } from '@/components/shared/LoadingState';
|
||||
|
||||
interface AuthLayoutProps {
|
||||
children: ReactNode;
|
||||
requireAuth?: boolean;
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AuthLayout - Client-side session verification
|
||||
*
|
||||
* Responsibilities:
|
||||
* 1. Verify user session exists
|
||||
* 2. Show loading state while checking
|
||||
* 3. Redirect to login if no session
|
||||
* 4. Render children if authenticated
|
||||
*
|
||||
* Does NOT check permissions - that's the API's job
|
||||
*/
|
||||
export function AuthLayout({
|
||||
children,
|
||||
requireAuth = true,
|
||||
redirectTo = '/auth/login'
|
||||
}: AuthLayoutProps) {
|
||||
const router = useRouter();
|
||||
const { session, loading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!requireAuth) return;
|
||||
|
||||
// If done loading and no session, redirect
|
||||
if (!loading && !session) {
|
||||
const returnTo = window.location.pathname;
|
||||
router.push(`${redirectTo}?returnTo=${encodeURIComponent(returnTo)}`);
|
||||
}
|
||||
}, [loading, session, router, requireAuth, redirectTo]);
|
||||
|
||||
// Show loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-deep-graphite">
|
||||
<LoadingState message="Verifying authentication..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show nothing while redirecting (or show error if not redirecting)
|
||||
if (requireAuth && !session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Render protected content
|
||||
return <>{children}</>;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Role-Based Layout (Client Layer)
|
||||
|
||||
**File**: `apps/website/lib/guards/RoleLayout.tsx`
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { LoadingState } from '@/components/shared/LoadingState';
|
||||
|
||||
interface RoleLayoutProps {
|
||||
children: ReactNode;
|
||||
requiredRoles: string[];
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* RoleLayout - Client-side role verification
|
||||
*
|
||||
* Responsibilities:
|
||||
* 1. Verify user session exists
|
||||
* 2. Show loading state
|
||||
* 3. Redirect if no session OR insufficient role
|
||||
* 4. Render children if authorized
|
||||
*
|
||||
* Note: This is UX enhancement. API is still source of truth.
|
||||
*/
|
||||
export function RoleLayout({
|
||||
children,
|
||||
requiredRoles,
|
||||
redirectTo = '/auth/login'
|
||||
}: RoleLayoutProps) {
|
||||
const router = useRouter();
|
||||
const { session, loading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
|
||||
// No session? Redirect
|
||||
if (!session) {
|
||||
const returnTo = window.location.pathname;
|
||||
router.push(`${redirectTo}?returnTo=${encodeURIComponent(returnTo)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Has session but wrong role? Redirect
|
||||
if (requiredRoles.length > 0 && !requiredRoles.includes(session.role || '')) {
|
||||
// Could redirect to dashboard or show access denied
|
||||
router.push('/dashboard');
|
||||
return;
|
||||
}
|
||||
}, [loading, session, router, requiredRoles, redirectTo]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-deep-graphite">
|
||||
<LoadingState message="Verifying access..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session || (requiredRoles.length > 0 && !requiredRoles.includes(session.role || ''))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Usage Examples
|
||||
|
||||
#### Public Route (No Protection)
|
||||
```typescript
|
||||
// app/leagues/page.tsx
|
||||
export default function LeaguesPage() {
|
||||
return <LeaguesList />;
|
||||
}
|
||||
```
|
||||
|
||||
#### Authenticated Route
|
||||
```typescript
|
||||
// app/dashboard/layout.tsx
|
||||
import { AuthLayout } from '@/lib/guards/AuthLayout';
|
||||
|
||||
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<AuthLayout requireAuth={true}>
|
||||
<div className="min-h-screen bg-deep-graphite">
|
||||
{children}
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// app/dashboard/page.tsx
|
||||
export default function DashboardPage() {
|
||||
// No additional auth checks needed - layout handles it
|
||||
return <DashboardContent />;
|
||||
}
|
||||
```
|
||||
|
||||
#### Role-Protected Route
|
||||
```typescript
|
||||
// app/admin/layout.tsx
|
||||
import { RoleLayout } from '@/lib/guards/RoleLayout';
|
||||
|
||||
export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<RoleLayout requiredRoles={['owner', 'admin']}>
|
||||
<div className="min-h-screen bg-deep-graphite">
|
||||
{children}
|
||||
</div>
|
||||
</RoleLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// app/admin/page.tsx
|
||||
export default function AdminPage() {
|
||||
// No additional checks - layout handles role verification
|
||||
return <AdminDashboard />;
|
||||
}
|
||||
```
|
||||
|
||||
#### 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
|
||||
}: {
|
||||
children: ReactNode;
|
||||
params: { id: string };
|
||||
}) {
|
||||
return (
|
||||
<AuthLayout requireAuth={true}>
|
||||
<LeagueAccessGuard leagueId={params.id}>
|
||||
<div className="min-h-screen bg-deep-graphite">
|
||||
{children}
|
||||
</div>
|
||||
</LeagueAccessGuard>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: API Guard Cleanup
|
||||
|
||||
**File**: `apps/api/src/domain/auth/AuthorizationGuard.ts`
|
||||
|
||||
```typescript
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthorizationService } from './AuthorizationService';
|
||||
import { PUBLIC_ROUTE_METADATA_KEY } from './Public';
|
||||
import { REQUIRE_ROLES_METADATA_KEY, RequireRolesMetadata } from './RequireRoles';
|
||||
|
||||
type AuthenticatedRequest = {
|
||||
user?: { userId: string };
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AuthorizationGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly authorizationService: AuthorizationService,
|
||||
) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const handler = context.getHandler();
|
||||
const controllerClass = context.getClass();
|
||||
|
||||
// 1. Check if route is public
|
||||
const isPublic = this.reflector.getAllAndOverride<{ public: true } | undefined>(
|
||||
PUBLIC_ROUTE_METADATA_KEY,
|
||||
[handler, controllerClass],
|
||||
)?.public ?? false;
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. Get required roles
|
||||
const rolesMetadata = this.reflector.getAllAndOverride<RequireRolesMetadata | undefined>(
|
||||
REQUIRE_ROLES_METADATA_KEY,
|
||||
[handler, controllerClass],
|
||||
) ?? null;
|
||||
|
||||
// 3. Get user identity from request (set by AuthenticationGuard)
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
const userId = request.user?.userId;
|
||||
|
||||
// 4. Deny if not authenticated
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException('Authentication required');
|
||||
}
|
||||
|
||||
// 5. If no roles required, allow
|
||||
if (!rolesMetadata || rolesMetadata.anyOf.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 6. Check if user has required role
|
||||
const userRoles = this.authorizationService.getRolesForUser(userId);
|
||||
const hasAnyRole = rolesMetadata.anyOf.some((r) => userRoles.includes(r));
|
||||
|
||||
if (!hasAnyRole) {
|
||||
throw new ForbiddenException(`Access requires one of: ${rolesMetadata.anyOf.join(', ')}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Client Error Handling
|
||||
|
||||
**File**: `apps/website/lib/api/client.ts`
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* API Client with unified error handling
|
||||
*/
|
||||
export async function apiFetch(url: string, options: RequestInit = {}) {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
// Handle authentication errors
|
||||
if (response.status === 401) {
|
||||
// Session expired or invalid
|
||||
window.location.href = '/auth/login?returnTo=' + encodeURIComponent(window.location.pathname);
|
||||
throw new Error('Authentication required');
|
||||
}
|
||||
|
||||
// Handle authorization errors
|
||||
if (response.status === 403) {
|
||||
const error = await response.json().catch(() => ({ message: 'Access denied' }));
|
||||
throw new Error(error.message || 'You do not have permission to access this resource');
|
||||
}
|
||||
|
||||
// Handle not found
|
||||
if (response.status === 404) {
|
||||
throw new Error('Resource not found');
|
||||
}
|
||||
|
||||
// Handle server errors
|
||||
if (response.status >= 500) {
|
||||
throw new Error('Server error. Please try again later.');
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
```
|
||||
|
||||
## Benefits of This Architecture
|
||||
|
||||
### 1. **Clear Responsibilities**
|
||||
- Server: Security and authorization
|
||||
- Client: UX and user experience
|
||||
|
||||
### 2. **Predictable Flow**
|
||||
```
|
||||
User → Middleware → API → Guard → Controller → Response → Client
|
||||
```
|
||||
|
||||
### 3. **Easy Debugging**
|
||||
- Check middleware logs
|
||||
- Check API guard logs
|
||||
- Check client session state
|
||||
|
||||
### 4. **Secure by Default**
|
||||
- API never trusts client
|
||||
- Client never makes security decisions
|
||||
- Defense in depth without confusion
|
||||
|
||||
### 5. **Scalable**
|
||||
- Easy to add new routes
|
||||
- Easy to add new roles
|
||||
- Easy to add new scopes
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### Phase 1: Clean Up Middleware (1 day)
|
||||
- [ ] Simplify `middleware.ts` to only check session cookie
|
||||
- [ ] Remove role logic from middleware
|
||||
- [ ] Define clear public routes list
|
||||
|
||||
### Phase 2: Create Clean Guards (2 days)
|
||||
- [ ] Create `AuthLayout` component
|
||||
- [ ] Create `RoleLayout` component
|
||||
- [ ] Create `ScopedLayout` component
|
||||
- [ ] Remove old RouteGuard/AuthGuard complexity
|
||||
|
||||
### Phase 3: Update Route Layouts (2 days)
|
||||
- [ ] Update all protected route layouts
|
||||
- [ ] Remove redundant page-level checks
|
||||
- [ ] Test all redirect flows
|
||||
|
||||
### Phase 4: API Guard Enhancement (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`
|
||||
- [ ] `/profile` → Redirects to `/auth/login?returnTo=/profile`
|
||||
- [ ] `/admin` → Redirects to `/auth/login?returnTo=/admin`
|
||||
- [ ] `/leagues` → Works (public)
|
||||
- [ ] `/auth/login` → Works (public)
|
||||
|
||||
### Authenticated User (Regular)
|
||||
- [ ] `/dashboard` → Works
|
||||
- [ ] `/profile` → Works
|
||||
- [ ] `/admin` → Redirects to `/dashboard` (no role)
|
||||
- [ ] `/leagues` → Works (public)
|
||||
- [ ] `/auth/login` → Redirects to `/dashboard`
|
||||
|
||||
### Authenticated User (Admin)
|
||||
- [ ] `/dashboard` → Works
|
||||
- [ ] `/profile` → Works
|
||||
- [ ] `/admin` → Works
|
||||
- [ ] `/admin/users` → Works
|
||||
- [ ] `/leagues` → Works (public)
|
||||
|
||||
### 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 "fucking unpredictable mess" by:
|
||||
|
||||
1. **One Source of Truth**: API server handles all security
|
||||
2. **Clear Layers**: Middleware → API → Guards → Controller
|
||||
3. **Simple Client**: UX enhancement only, no security decisions
|
||||
4. **Predictable Flow**: Always the same path for every request
|
||||
5. **Easy to Debug**: Each layer has one job
|
||||
|
||||
The result: **Clean, predictable, secure authentication and authorization that just works.**
|
||||
Reference in New Issue
Block a user