This commit is contained in:
2026-01-11 14:42:54 +01:00
parent 2f0b83f030
commit 90b6e73a22
27 changed files with 980 additions and 2513 deletions
+51
View 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)
@@ -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.
+27 -448
View File
@@ -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
View 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.
+20 -298
View File
@@ -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.
-115
View File
@@ -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
```
@@ -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.**