docs
This commit is contained in:
78
docs/architecture/api/API_DATA_FLOW.md
Normal file
78
docs/architecture/api/API_DATA_FLOW.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# API Data Flow (Strict)
|
||||
|
||||
This document defines the **apps/api** data flow and responsibilities.
|
||||
|
||||
API scope:
|
||||
|
||||
- `apps/api/**`
|
||||
|
||||
## 1) API role
|
||||
|
||||
The API is a **delivery application**.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- HTTP transport boundary
|
||||
- authentication and authorization enforcement
|
||||
- request validation (transport shape)
|
||||
- mapping between HTTP DTOs and Core inputs
|
||||
- calling Core use cases
|
||||
- mapping Core results into HTTP responses
|
||||
|
||||
## 2) API data types (strict)
|
||||
|
||||
### 2.1 Request DTO
|
||||
|
||||
Definition: HTTP request contract shape.
|
||||
|
||||
Rules:
|
||||
|
||||
- lives in the API layer
|
||||
- validated at the API boundary
|
||||
- never enters Core unchanged
|
||||
|
||||
### 2.2 Response DTO
|
||||
|
||||
Definition: HTTP response contract shape.
|
||||
|
||||
Rules:
|
||||
|
||||
- lives in the API layer
|
||||
- never contains domain objects
|
||||
|
||||
### 2.3 API Presenter
|
||||
|
||||
Definition: mapping logic from Core results to HTTP response DTOs.
|
||||
|
||||
Rules:
|
||||
|
||||
- pure transformation
|
||||
- no business rules
|
||||
- may hold state per request
|
||||
|
||||
## 3) Canonical flow
|
||||
|
||||
```text
|
||||
HTTP Request
|
||||
↓
|
||||
Guards (auth, authorization, feature availability)
|
||||
↓
|
||||
Controller (transport-only)
|
||||
↓
|
||||
Mapping: Request DTO → Core input
|
||||
↓
|
||||
Core Use Case
|
||||
↓
|
||||
Mapping: Core result → Response DTO (Presenter)
|
||||
↓
|
||||
HTTP Response
|
||||
```
|
||||
|
||||
## 4) Non-negotiable rules
|
||||
|
||||
1. Controllers contain no business rules.
|
||||
2. Controllers do not construct domain objects.
|
||||
3. Core results never leave the API without mapping.
|
||||
|
||||
See authorization model: [`docs/architecture/api/AUTHORIZATION.md`](docs/architecture/api/AUTHORIZATION.md:1).
|
||||
|
||||
38
docs/architecture/api/API_FILE_STRUCTURE.md
Normal file
38
docs/architecture/api/API_FILE_STRUCTURE.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# API File Structure (Strict)
|
||||
|
||||
This document defines the canonical **physical** structure for `apps/api/**`.
|
||||
|
||||
It describes where code lives, not the full behavioral rules.
|
||||
|
||||
## 1) API is feature-based
|
||||
|
||||
The API is organized by feature modules.
|
||||
|
||||
```text
|
||||
apps/api/
|
||||
src/
|
||||
domain/
|
||||
shared/
|
||||
```
|
||||
|
||||
Within feature modules:
|
||||
|
||||
```text
|
||||
apps/api/src/domain/<feature>/
|
||||
<Feature>Controller.ts
|
||||
<Feature>Service.ts
|
||||
<Feature>Module.ts
|
||||
dto/
|
||||
presenters/
|
||||
```
|
||||
|
||||
## 2) What belongs where (strict)
|
||||
|
||||
- Controllers: HTTP boundary only
|
||||
- DTOs: HTTP contracts only
|
||||
- Presenters: map Core results response DTOs
|
||||
|
||||
API flow rules:
|
||||
|
||||
- [`docs/architecture/api/API_DATA_FLOW.md`](docs/architecture/api/API_DATA_FLOW.md:1)
|
||||
|
||||
@@ -12,7 +12,7 @@ It complements (but does not replace) feature availability:
|
||||
- Authorization answers: “Is this actor allowed to do it?”
|
||||
|
||||
Related:
|
||||
- Feature gating concept: docs/architecture/FEATURE_AVAILABILITY.md
|
||||
- Feature gating concept: [`docs/architecture/shared/FEATURE_AVAILABILITY.md`](docs/architecture/shared/FEATURE_AVAILABILITY.md:1)
|
||||
|
||||
|
||||
---
|
||||
@@ -154,7 +154,7 @@ Return **404** when:
|
||||
Use this sparingly and intentionally.
|
||||
|
||||
### 6.3 Feature availability interaction
|
||||
Feature availability failures (disabled/hidden/coming soon) should behave as “not found” for public callers, while maintenance mode should return 503. See docs/architecture/FEATURE_AVAILABILITY.md.
|
||||
Feature availability failures (disabled/hidden/coming soon) should behave as “not found” for public callers, while maintenance mode should return 503. See [`docs/architecture/shared/FEATURE_AVAILABILITY.md`](docs/architecture/shared/FEATURE_AVAILABILITY.md:1).
|
||||
|
||||
|
||||
---
|
||||
@@ -253,4 +253,4 @@ Rules:
|
||||
- A super-admin UI can manage:
|
||||
- global roles (owner/admin)
|
||||
- scoped roles (league_owner/admin/steward, sponsor_owner/admin, team_owner/admin)
|
||||
- Feature availability remains a separate control plane (maintenance mode, coming soon, kill switches), documented in docs/architecture/FEATURE_AVAILABILITY.md.
|
||||
- Feature availability remains a separate control plane (maintenance mode, coming soon, kill switches), documented in [`docs/architecture/shared/FEATURE_AVAILABILITY.md`](docs/architecture/shared/FEATURE_AVAILABILITY.md:1).
|
||||
|
||||
47
docs/architecture/api/AUTH_FLOW.md
Normal file
47
docs/architecture/api/AUTH_FLOW.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Authentication and Authorization Flow (API)
|
||||
|
||||
This document defines how authentication and authorization are enforced in the API.
|
||||
|
||||
Shared contract:
|
||||
|
||||
- [`docs/architecture/shared/AUTH_CONTRACT.md`](docs/architecture/shared/AUTH_CONTRACT.md:1)
|
||||
|
||||
## 1) Enforcement location (strict)
|
||||
|
||||
All enforcement happens in the API.
|
||||
|
||||
The API must:
|
||||
|
||||
- authenticate the actor from the session
|
||||
- authorize the actor for the requested capability
|
||||
- deny requests deterministically with appropriate HTTP status
|
||||
|
||||
## 2) Canonical request flow
|
||||
|
||||
```text
|
||||
HTTP Request
|
||||
↓
|
||||
Authentication (resolve actor)
|
||||
↓
|
||||
Authorization (roles, permissions, scope)
|
||||
↓
|
||||
Controller (transport-only)
|
||||
↓
|
||||
Core Use Case
|
||||
↓
|
||||
Presenter mapping
|
||||
↓
|
||||
HTTP Response
|
||||
```
|
||||
|
||||
## 3) Non-negotiable rules
|
||||
|
||||
1. Deny by default unless explicitly public.
|
||||
2. The actor identity is derived from the session.
|
||||
3. Controllers do not contain business rules.
|
||||
|
||||
Related:
|
||||
|
||||
- Authorization model: [`docs/architecture/api/AUTHORIZATION.md`](docs/architecture/api/AUTHORIZATION.md:1)
|
||||
- Guards definition: [`docs/architecture/api/GUARDS.md`](docs/architecture/api/GUARDS.md:1)
|
||||
|
||||
47
docs/architecture/api/GUARDS.md
Normal file
47
docs/architecture/api/GUARDS.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Guards (API Enforcement)
|
||||
|
||||
This document defines **Guards** as API enforcement mechanisms.
|
||||
|
||||
Shared contract: [`docs/architecture/shared/BLOCKERS_AND_GUARDS.md`](docs/architecture/shared/BLOCKERS_AND_GUARDS.md:1)
|
||||
|
||||
## 1) Definition
|
||||
|
||||
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) Responsibilities
|
||||
|
||||
Guards MAY:
|
||||
|
||||
- block requests entirely
|
||||
- return HTTP errors (401, 403, 429)
|
||||
- enforce authentication and authorization
|
||||
- enforce rate limits
|
||||
- enforce feature availability
|
||||
- protect against abuse and attacks
|
||||
|
||||
Guards MUST:
|
||||
|
||||
- be deterministic
|
||||
- be authoritative
|
||||
- be security-relevant
|
||||
|
||||
## 3) Restrictions
|
||||
|
||||
Guards MUST NOT:
|
||||
|
||||
- depend on website/client state
|
||||
- contain UI logic
|
||||
- attempt to improve UX
|
||||
- assume the client behaved correctly
|
||||
|
||||
## 4) Common Guards
|
||||
|
||||
- AuthGuard
|
||||
- RolesGuard
|
||||
- PermissionsGuard
|
||||
- Throttler/RateLimit guards
|
||||
- CSRF guards
|
||||
- Feature availability guards
|
||||
|
||||
38
docs/architecture/api/USE_CASE_WIRING.md
Normal file
38
docs/architecture/api/USE_CASE_WIRING.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Use Case Wiring (API) (Strict)
|
||||
|
||||
This document defines how the API wires HTTP requests to Core Use Cases.
|
||||
|
||||
Core contract:
|
||||
|
||||
- [`docs/architecture/core/USECASES.md`](docs/architecture/core/USECASES.md:1)
|
||||
|
||||
## 1) Non-negotiable rules
|
||||
|
||||
1. Controllers are transport boundaries.
|
||||
2. Controllers validate request DTOs.
|
||||
3. Controllers map request DTOs to Core inputs.
|
||||
4. Controllers execute Core Use Cases.
|
||||
5. Controllers map Core results to response DTOs.
|
||||
|
||||
## 2) Presenter meaning in the API
|
||||
|
||||
In the API, a Presenter is an output adapter that maps Core results to HTTP response DTOs.
|
||||
|
||||
Rule:
|
||||
|
||||
- API presenters are request-scoped. They must not be shared across concurrent requests.
|
||||
|
||||
## 3) Canonical flow
|
||||
|
||||
```text
|
||||
HTTP Request DTO
|
||||
↓
|
||||
Controller mapping
|
||||
↓
|
||||
Core Use Case
|
||||
↓
|
||||
Presenter mapping
|
||||
↓
|
||||
HTTP Response DTO
|
||||
```
|
||||
|
||||
92
docs/architecture/core/CORE_DATA_FLOW.md
Normal file
92
docs/architecture/core/CORE_DATA_FLOW.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Core Data Flow (Strict)
|
||||
|
||||
This document defines the **Core** data flow rules and boundaries.
|
||||
|
||||
Core scope:
|
||||
|
||||
- `core/**`
|
||||
|
||||
Core does not know:
|
||||
|
||||
- HTTP
|
||||
- Next.js
|
||||
- databases
|
||||
- DTOs
|
||||
- UI models
|
||||
|
||||
## 1) Layers inside Core
|
||||
|
||||
Core contains two inner layers:
|
||||
|
||||
- Domain
|
||||
- Application
|
||||
|
||||
### 1.1 Domain
|
||||
|
||||
Domain is business truth.
|
||||
|
||||
Allowed:
|
||||
|
||||
- Entities
|
||||
- Value Objects
|
||||
- Domain Services
|
||||
|
||||
Forbidden:
|
||||
|
||||
- DTOs
|
||||
- frameworks
|
||||
- IO
|
||||
|
||||
See [`docs/architecture/core/DOMAIN_OBJECTS.md`](docs/architecture/core/DOMAIN_OBJECTS.md:1).
|
||||
|
||||
### 1.2 Application
|
||||
|
||||
Application coordinates business intents.
|
||||
|
||||
Allowed:
|
||||
|
||||
- Use Cases (commands and queries)
|
||||
- Application-level ports (repository ports, gateways)
|
||||
|
||||
Forbidden:
|
||||
|
||||
- HTTP
|
||||
- persistence implementations
|
||||
- frontend models
|
||||
|
||||
## 2) Core I/O boundary
|
||||
|
||||
All communication across the Core boundary occurs through **ports**.
|
||||
|
||||
Rules:
|
||||
|
||||
- Port interfaces live in Core.
|
||||
- Implementations live outside Core.
|
||||
|
||||
## 3) Core data types (strict)
|
||||
|
||||
- Use Case inputs are plain data and/or domain types.
|
||||
- Use Case outputs are plain data and/or domain types.
|
||||
|
||||
Core MUST NOT define HTTP DTOs.
|
||||
|
||||
## 4) Canonical flow
|
||||
|
||||
```text
|
||||
Delivery App (HTTP or Website)
|
||||
↓
|
||||
Core Application (Use Case)
|
||||
↓
|
||||
Core Domain (Entities, Value Objects)
|
||||
↓
|
||||
Ports (repository, gateway)
|
||||
↓
|
||||
Adapter implementation (outside Core)
|
||||
```
|
||||
|
||||
## 5) Non-negotiable rules
|
||||
|
||||
1. Core is framework-agnostic.
|
||||
2. DTOs do not enter Core.
|
||||
3. Core defines ports; outer layers implement them.
|
||||
|
||||
57
docs/architecture/core/CORE_FILE_STRUCTURE.md
Normal file
57
docs/architecture/core/CORE_FILE_STRUCTURE.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Core File Structure (Strict)
|
||||
|
||||
This document defines the canonical **physical** structure for `core/`.
|
||||
|
||||
It describes where code lives, not the full behavioral rules.
|
||||
|
||||
Core rules and responsibilities are defined elsewhere.
|
||||
|
||||
## 1) Core is feature-based
|
||||
|
||||
Core is organized by bounded context / feature.
|
||||
|
||||
```text
|
||||
core/
|
||||
shared/
|
||||
<context>/
|
||||
domain/
|
||||
application/
|
||||
```
|
||||
|
||||
## 2) `core/<context>/domain/`
|
||||
|
||||
Domain contains business truth.
|
||||
|
||||
Canonical folders:
|
||||
|
||||
```text
|
||||
core/<context>/domain/
|
||||
entities/
|
||||
value-objects/
|
||||
services/
|
||||
events/
|
||||
errors/
|
||||
```
|
||||
|
||||
See [`docs/architecture/core/DOMAIN_OBJECTS.md`](docs/architecture/core/DOMAIN_OBJECTS.md:1).
|
||||
|
||||
## 3) `core/<context>/application/`
|
||||
|
||||
Application coordinates business intents.
|
||||
|
||||
Canonical folders:
|
||||
|
||||
```text
|
||||
core/<context>/application/
|
||||
commands/
|
||||
queries/
|
||||
use-cases/
|
||||
services/
|
||||
ports/
|
||||
```
|
||||
|
||||
See:
|
||||
|
||||
- [`docs/architecture/core/USECASES.md`](docs/architecture/core/USECASES.md:1)
|
||||
- [`docs/architecture/core/CQRS.md`](docs/architecture/core/CQRS.md:1)
|
||||
|
||||
@@ -196,15 +196,16 @@ Avoid CQRS Light when:
|
||||
|
||||
⸻
|
||||
|
||||
12. Migration Path
|
||||
12. Adoption Rule (Strict)
|
||||
|
||||
CQRS Light allows incremental adoption:
|
||||
1. Start with classic Clean Architecture
|
||||
2. Separate commands and queries logically
|
||||
3. Optimize read paths as needed
|
||||
4. Introduce events or projections later (optional)
|
||||
CQRS Light is a structural rule inside Core.
|
||||
|
||||
No rewrites required.
|
||||
If CQRS Light is used:
|
||||
|
||||
- commands and queries MUST be separated by responsibility
|
||||
- queries MUST remain read-only and must not enforce invariants
|
||||
|
||||
This document does not define a migration plan.
|
||||
|
||||
⸻
|
||||
|
||||
@@ -241,4 +242,4 @@ Without:
|
||||
• Event sourcing complexity
|
||||
• Premature optimization
|
||||
|
||||
It is the safest way to gain CQRS benefits while staying true to Clean Architecture.
|
||||
It is the safest way to gain CQRS benefits while staying true to Clean Architecture.
|
||||
|
||||
@@ -1,339 +0,0 @@
|
||||
Enums in Clean Architecture (Strict & Final)
|
||||
|
||||
This document defines how enums are modeled, placed, and used in a strict Clean Architecture setup.
|
||||
|
||||
Enums are one of the most common sources of architectural leakage. This guide removes all ambiguity.
|
||||
|
||||
⸻
|
||||
|
||||
1. Core Principle
|
||||
|
||||
Enums represent knowledge.
|
||||
Knowledge must live where it is true.
|
||||
|
||||
Therefore:
|
||||
• Not every enum is a domain enum
|
||||
• Enums must never cross architectural boundaries blindly
|
||||
• Ports must remain neutral
|
||||
|
||||
⸻
|
||||
|
||||
2. Enum Categories (Authoritative)
|
||||
|
||||
There are four and only four valid enum categories:
|
||||
1. Domain Enums
|
||||
2. Application (Workflow) Enums
|
||||
3. Transport Enums (API)
|
||||
4. UI Enums (Frontend)
|
||||
|
||||
Each category has strict placement and usage rules.
|
||||
|
||||
⸻
|
||||
|
||||
3. Domain Enums
|
||||
|
||||
Definition
|
||||
|
||||
A Domain Enum represents a business concept that:
|
||||
• has meaning in the domain
|
||||
• affects rules or invariants
|
||||
• is part of the ubiquitous language
|
||||
|
||||
Examples:
|
||||
• LeagueVisibility
|
||||
• MembershipRole
|
||||
• RaceStatus
|
||||
• SponsorshipTier
|
||||
• PenaltyType
|
||||
|
||||
⸻
|
||||
|
||||
Placement
|
||||
|
||||
core/<context>/domain/
|
||||
├── value-objects/
|
||||
│ └── LeagueVisibility.ts
|
||||
└── entities/
|
||||
|
||||
Preferred: model domain enums as Value Objects instead of enum keywords.
|
||||
|
||||
⸻
|
||||
|
||||
Example (Value Object)
|
||||
|
||||
export class LeagueVisibility {
|
||||
private constructor(private readonly value: 'public' | 'private') {}
|
||||
|
||||
static from(value: string): LeagueVisibility {
|
||||
if (value !== 'public' && value !== 'private') {
|
||||
throw new DomainError('Invalid LeagueVisibility');
|
||||
}
|
||||
return new LeagueVisibility(value);
|
||||
}
|
||||
|
||||
isPublic(): boolean {
|
||||
return this.value === 'public';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
Usage Rules
|
||||
|
||||
Allowed:
|
||||
• Domain
|
||||
• Use Cases
|
||||
|
||||
Forbidden:
|
||||
• Ports
|
||||
• Adapters
|
||||
• API DTOs
|
||||
• Frontend
|
||||
|
||||
Domain enums must never cross a Port boundary.
|
||||
|
||||
⸻
|
||||
|
||||
4. Application Enums (Workflow Enums)
|
||||
|
||||
Definition
|
||||
|
||||
Application Enums represent internal workflow or state coordination.
|
||||
|
||||
They are not business truth and must not leak.
|
||||
|
||||
Examples:
|
||||
• LeagueSetupStep
|
||||
• ImportPhase
|
||||
• ProcessingState
|
||||
|
||||
⸻
|
||||
|
||||
Placement
|
||||
|
||||
core/<context>/application/internal/
|
||||
└── LeagueSetupStep.ts
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
Example
|
||||
|
||||
export enum LeagueSetupStep {
|
||||
CreateLeague,
|
||||
CreateSeason,
|
||||
AssignOwner,
|
||||
Notify
|
||||
}
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
Usage Rules
|
||||
|
||||
Allowed:
|
||||
• Application Services
|
||||
• Use Cases
|
||||
|
||||
Forbidden:
|
||||
• Domain
|
||||
• Ports
|
||||
• Adapters
|
||||
• Frontend
|
||||
|
||||
These enums must remain strictly internal.
|
||||
|
||||
⸻
|
||||
|
||||
5. Transport Enums (API DTOs)
|
||||
|
||||
Definition
|
||||
|
||||
Transport Enums describe allowed values in HTTP contracts.
|
||||
They exist purely to constrain transport data, not to encode business rules.
|
||||
|
||||
Naming rule:
|
||||
|
||||
Transport enums MUST end with Enum.
|
||||
|
||||
This makes enums immediately recognizable in code reviews and prevents silent leakage.
|
||||
|
||||
Examples:
|
||||
• LeagueVisibilityEnum
|
||||
• SponsorshipStatusEnum
|
||||
• PenaltyTypeEnum
|
||||
|
||||
⸻
|
||||
|
||||
Placement
|
||||
|
||||
apps/api/<feature>/dto/
|
||||
└── LeagueVisibilityEnum.ts
|
||||
|
||||
Website mirrors the same naming:
|
||||
|
||||
apps/website/lib/dtos/
|
||||
└── LeagueVisibilityEnum.ts
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
Example
|
||||
|
||||
export enum LeagueVisibilityEnum {
|
||||
Public = 'public',
|
||||
Private = 'private'
|
||||
}
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
Usage Rules
|
||||
|
||||
Allowed:
|
||||
• API Controllers
|
||||
• API Presenters
|
||||
• Website API DTOs
|
||||
|
||||
Forbidden:
|
||||
• Core Domain
|
||||
• Use Cases
|
||||
|
||||
Transport enums are copies, never reexports of domain enums.
|
||||
|
||||
⸻
|
||||
|
||||
Placement
|
||||
|
||||
apps/api/<feature>/dto/
|
||||
└── LeagueVisibilityDto.ts
|
||||
|
||||
or inline as union types in DTOs.
|
||||
|
||||
⸻
|
||||
|
||||
Example
|
||||
|
||||
export type LeagueVisibilityDto = 'public' | 'private';
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
Usage Rules
|
||||
|
||||
Allowed:
|
||||
• API Controllers
|
||||
• API Presenters
|
||||
• Website API DTOs
|
||||
|
||||
Forbidden:
|
||||
• Core Domain
|
||||
• Use Cases
|
||||
|
||||
Transport enums are copies, never reexports of domain enums.
|
||||
|
||||
⸻
|
||||
|
||||
6. UI Enums (Frontend)
|
||||
|
||||
Definition
|
||||
|
||||
UI Enums describe presentation or interaction state.
|
||||
|
||||
They have no business meaning.
|
||||
|
||||
Examples:
|
||||
• WizardStep
|
||||
• SortOrder
|
||||
• ViewMode
|
||||
• TabKey
|
||||
|
||||
⸻
|
||||
|
||||
Placement
|
||||
|
||||
apps/website/lib/ui/
|
||||
└── LeagueWizardStep.ts
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
Example
|
||||
|
||||
export enum LeagueWizardStep {
|
||||
Basics,
|
||||
Structure,
|
||||
Scoring,
|
||||
Review
|
||||
}
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
Usage Rules
|
||||
|
||||
Allowed:
|
||||
• Frontend only
|
||||
|
||||
Forbidden:
|
||||
• Core
|
||||
• API
|
||||
|
||||
⸻
|
||||
|
||||
7. Absolute Prohibitions
|
||||
|
||||
❌ Enums in Ports
|
||||
|
||||
// ❌ forbidden
|
||||
export interface CreateLeagueInputPort {
|
||||
visibility: LeagueVisibility;
|
||||
}
|
||||
|
||||
✅ Correct
|
||||
|
||||
export interface CreateLeagueInputPort {
|
||||
visibility: 'public' | 'private';
|
||||
}
|
||||
|
||||
Mapping happens inside the Use Case:
|
||||
|
||||
const visibility = LeagueVisibility.from(input.visibility);
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
8. Decision Checklist
|
||||
|
||||
Ask these questions:
|
||||
1. Does changing this enum change business rules?
|
||||
• Yes → Domain Enum
|
||||
• No → continue
|
||||
2. Is it only needed for internal workflow coordination?
|
||||
• Yes → Application Enum
|
||||
• No → continue
|
||||
3. Is it part of an HTTP contract?
|
||||
• Yes → Transport Enum
|
||||
• No → continue
|
||||
4. Is it purely for UI state?
|
||||
• Yes → UI Enum
|
||||
|
||||
⸻
|
||||
|
||||
9. Summary Table
|
||||
|
||||
Enum Type Location May Cross Ports Scope
|
||||
Domain Enum core/domain ❌ No Business rules
|
||||
Application Enum core/application ❌ No Workflow only
|
||||
Transport Enum apps/api + website ❌ No HTTP contracts
|
||||
UI Enum apps/website ❌ No Presentation only
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
10. Final Rule (Non-Negotiable)
|
||||
|
||||
If an enum crosses a boundary, it is in the wrong place.
|
||||
|
||||
This rule alone prevents most long-term architectural decay.
|
||||
@@ -1,513 +1,62 @@
|
||||
Use Case Architecture Guide
|
||||
# Use Cases (Core Application Boundary) (Strict)
|
||||
|
||||
This document defines the correct structure and responsibilities of Application Use Cases
|
||||
according to Clean Architecture, in a NestJS-based system.
|
||||
This document defines the strict rules for Core Use Cases.
|
||||
|
||||
The goal is:
|
||||
• strict separation of concerns
|
||||
• correct terminology (no fake "ports")
|
||||
• minimal abstractions
|
||||
• long-term consistency
|
||||
Scope:
|
||||
|
||||
This is the canonical reference for all use cases in this codebase.
|
||||
- `core/**`
|
||||
|
||||
~
|
||||
Non-scope:
|
||||
|
||||
1. Core Concepts (Authoritative Definitions)
|
||||
- HTTP controllers
|
||||
- DTOs
|
||||
- Next.js pages
|
||||
|
||||
Use Case
|
||||
• Encapsulates application-level business logic
|
||||
• Is the Input Port
|
||||
• Is injected via DI
|
||||
• Knows no API, no DTOs, no transport
|
||||
• Coordinates domain objects and infrastructure
|
||||
## 1) Definition
|
||||
|
||||
The public execute() method is the input port.
|
||||
A Use Case represents one business intent.
|
||||
|
||||
~
|
||||
It answers:
|
||||
|
||||
Input
|
||||
• Pure data
|
||||
• Not a port
|
||||
• Not an interface
|
||||
• May be omitted if the use case has no parameters
|
||||
- what the system does
|
||||
|
||||
type GetSponsorsInput = {
|
||||
leagueId: LeagueId
|
||||
}
|
||||
## 2) Non-negotiable rules
|
||||
|
||||
1. Use Cases contain business logic.
|
||||
2. Use Cases enforce invariants.
|
||||
3. Use Cases do not know about HTTP.
|
||||
4. Use Cases do not know about UI.
|
||||
5. Use Cases do not depend on delivery-layer presenters.
|
||||
6. Use Cases do not accept or return HTTP DTOs.
|
||||
|
||||
~
|
||||
## 3) Inputs and outputs
|
||||
|
||||
Result
|
||||
• The business outcome of a use case
|
||||
• May contain Entities and Value Objects
|
||||
• Not a DTO
|
||||
• Never leaves the core directly
|
||||
Inputs:
|
||||
|
||||
type GetSponsorsResult = {
|
||||
sponsors: Sponsor[]
|
||||
}
|
||||
- plain data and/or domain types
|
||||
|
||||
Outputs:
|
||||
|
||||
~
|
||||
- a `Result` containing plain data and/or domain types
|
||||
|
||||
Output Port
|
||||
• A behavioral boundary
|
||||
• Defines how the core communicates outward
|
||||
• Never a data structure
|
||||
• Lives in the Application Layer
|
||||
Rule:
|
||||
|
||||
export interface UseCaseOutputPort<T> {
|
||||
present(data: T): void
|
||||
}
|
||||
- mapping to and from HTTP DTOs happens in the API, not in the Core.
|
||||
|
||||
See API wiring: [`docs/architecture/api/USE_CASE_WIRING.md`](docs/architecture/api/USE_CASE_WIRING.md:1)
|
||||
|
||||
~
|
||||
## 4) Ports
|
||||
|
||||
Presenter
|
||||
• Implements UseCaseOutputPort<T>
|
||||
• Lives in the API / UI layer
|
||||
• Translates Result → ViewModel / DTO
|
||||
• Holds internal state
|
||||
• Is pulled by the controller after execution
|
||||
|
||||
~
|
||||
|
||||
2. Canonical Use Case Structure
|
||||
|
||||
Application Layer
|
||||
|
||||
Use Case
|
||||
|
||||
@Injectable()
|
||||
export class GetSponsorsUseCase {
|
||||
constructor(
|
||||
private readonly sponsorRepository: ISponsorRepository,
|
||||
private readonly output: UseCaseOutputPort<GetSponsorsResult>,
|
||||
) {}
|
||||
|
||||
async execute(): Promise<Result<void, ApplicationError>> {
|
||||
const sponsors = await this.sponsorRepository.findAll()
|
||||
|
||||
this.output.present({ sponsors })
|
||||
|
||||
return Result.ok(undefined)
|
||||
}
|
||||
}
|
||||
Use Cases depend on ports for IO.
|
||||
|
||||
Rules:
|
||||
• execute() is the Input Port
|
||||
• The use case does not return result data
|
||||
• All output flows through the OutputPort
|
||||
• The return value signals success or failure only
|
||||
|
||||
### ⚠️ ARCHITECTURAL VIOLATION ALERT
|
||||
- port interfaces live in Core
|
||||
- implementations live in adapters or delivery apps
|
||||
|
||||
**The pattern shown above is INCORRECT and violates Clean Architecture.**
|
||||
## 5) CQRS
|
||||
|
||||
#### ❌ WRONG PATTERN (What NOT to do)
|
||||
If CQRS-light is used, commands and queries are separated by responsibility.
|
||||
|
||||
```typescript
|
||||
@Injectable()
|
||||
export class GetSponsorsUseCase {
|
||||
constructor(
|
||||
private readonly sponsorRepository: ISponsorRepository,
|
||||
private readonly output: UseCaseOutputPort<GetSponsorsResult>,
|
||||
) {}
|
||||
See [`docs/architecture/core/CQRS.md`](docs/architecture/core/CQRS.md:1).
|
||||
|
||||
async execute(): Promise<Result<void, ApplicationError>> {
|
||||
const sponsors = await this.sponsorRepository.findAll()
|
||||
|
||||
this.output.present({ sponsors }) // ❌ WRONG: Use case calling presenter
|
||||
return Result.ok(undefined)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why this violates Clean Architecture:**
|
||||
- Use cases **know about presenters** and how to call them
|
||||
- Creates **tight coupling** between application logic and presentation
|
||||
- Makes use cases **untestable** without mocking presenters
|
||||
- Violates the **Dependency Rule** (inner layer depending on outer layer behavior)
|
||||
|
||||
#### ✅ CORRECT PATTERN (Clean Architecture)
|
||||
|
||||
```typescript
|
||||
@Injectable()
|
||||
export class GetSponsorsUseCase {
|
||||
constructor(
|
||||
private readonly sponsorRepository: ISponsorRepository,
|
||||
// NO output port needed in constructor
|
||||
) {}
|
||||
|
||||
async execute(): Promise<Result<GetSponsorsResult, ApplicationError>> {
|
||||
const sponsors = await this.sponsorRepository.findAll()
|
||||
|
||||
return Result.ok({ sponsors })
|
||||
// ✅ Returns Result, period. No .present() call.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**The Controller (in API layer) handles the wiring:**
|
||||
|
||||
```typescript
|
||||
@Controller('/sponsors')
|
||||
export class SponsorsController {
|
||||
constructor(
|
||||
private readonly useCase: GetSponsorsUseCase,
|
||||
private readonly presenter: GetSponsorsPresenter,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async getSponsors() {
|
||||
// 1. Execute use case
|
||||
const result = await this.useCase.execute()
|
||||
|
||||
if (result.isErr()) {
|
||||
throw mapApplicationError(result.unwrapErr())
|
||||
}
|
||||
|
||||
// 2. Wire to presenter
|
||||
this.presenter.present(result.value)
|
||||
|
||||
// 3. Return ViewModel
|
||||
return this.presenter.getViewModel()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**This is the ONLY pattern that respects Clean Architecture.**
|
||||
|
||||
~
|
||||
|
||||
Result Model
|
||||
|
||||
type GetSponsorsResult = {
|
||||
sponsors: Sponsor[]
|
||||
}
|
||||
|
||||
Rules:
|
||||
• Domain objects are allowed
|
||||
• No DTOs
|
||||
• No interfaces
|
||||
• No transport concerns
|
||||
|
||||
~
|
||||
|
||||
3. API Layer
|
||||
|
||||
API Services / Controllers (Thin Orchestration)
|
||||
|
||||
The API layer is a transport boundary. It MUST delegate business logic to `./core`:
|
||||
|
||||
• orchestrate auth + authorization checks (actor/session/roles)
|
||||
• collect/validate transport input (DTOs at the boundary)
|
||||
• execute a Core use case (entities/value objects live here)
|
||||
• map Result → DTO / ViewModel via a Presenter (presenter owns mapping)
|
||||
|
||||
Rules:
|
||||
• Controllers stay thin: no business rules, no domain validation, no decision-making
|
||||
• API services orchestrate: auth + use case execution + presenter mapping
|
||||
• Domain objects never cross the API boundary un-mapped
|
||||
|
||||
Presenter
|
||||
|
||||
@Injectable()
|
||||
export class GetSponsorsPresenter
|
||||
implements UseCaseOutputPort<GetSponsorsResult>
|
||||
{
|
||||
private viewModel!: GetSponsorsViewModel
|
||||
|
||||
present(result: GetSponsorsResult): void {
|
||||
this.viewModel = {
|
||||
sponsors: result.sponsors.map(s => ({
|
||||
id: s.id.value,
|
||||
name: s.name,
|
||||
websiteUrl: s.websiteUrl,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
getViewModel(): GetSponsorsViewModel {
|
||||
return this.viewModel
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
~
|
||||
|
||||
Controller
|
||||
|
||||
@Controller('/sponsors')
|
||||
export class SponsorsController {
|
||||
constructor(
|
||||
private readonly useCase: GetSponsorsUseCase,
|
||||
private readonly presenter: GetSponsorsPresenter,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async getSponsors() {
|
||||
const result = await this.useCase.execute()
|
||||
|
||||
if (result.isErr()) {
|
||||
throw mapApplicationError(result.unwrapErr())
|
||||
}
|
||||
|
||||
return this.presenter.getViewModel()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
~
|
||||
|
||||
Payments Example
|
||||
|
||||
Application Layer
|
||||
|
||||
Use Case
|
||||
|
||||
@Injectable()
|
||||
export class CreatePaymentUseCase {
|
||||
constructor(
|
||||
private readonly paymentRepository: IPaymentRepository,
|
||||
private readonly output: UseCaseOutputPort<CreatePaymentResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: CreatePaymentInput): Promise<Result<void, ApplicationErrorCode<CreatePaymentErrorCode>>> {
|
||||
// business logic
|
||||
const payment = await this.paymentRepository.create(payment);
|
||||
|
||||
this.output.present({ payment });
|
||||
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
Result Model
|
||||
|
||||
type CreatePaymentResult = {
|
||||
payment: Payment;
|
||||
};
|
||||
|
||||
API Layer
|
||||
|
||||
Presenter
|
||||
|
||||
@Injectable()
|
||||
export class CreatePaymentPresenter
|
||||
implements UseCaseOutputPort<CreatePaymentResult>
|
||||
{
|
||||
private viewModel: CreatePaymentViewModel | null = null;
|
||||
|
||||
present(result: CreatePaymentResult): void {
|
||||
this.viewModel = {
|
||||
payment: this.mapPaymentToDto(result.payment),
|
||||
};
|
||||
}
|
||||
|
||||
getViewModel(): CreatePaymentViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.viewModel = null;
|
||||
}
|
||||
|
||||
private mapPaymentToDto(payment: Payment): PaymentDto {
|
||||
return {
|
||||
id: payment.id,
|
||||
// ... other fields
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Controller
|
||||
|
||||
@Controller('/payments')
|
||||
export class PaymentsController {
|
||||
constructor(
|
||||
private readonly useCase: CreatePaymentUseCase,
|
||||
private readonly presenter: CreatePaymentPresenter,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
async createPayment(@Body() input: CreatePaymentInput) {
|
||||
const result = await this.useCase.execute(input);
|
||||
|
||||
if (result.isErr()) {
|
||||
throw mapApplicationError(result.unwrapErr());
|
||||
}
|
||||
|
||||
return this.presenter.getViewModel();
|
||||
}
|
||||
}
|
||||
|
||||
~
|
||||
|
||||
4. Module Wiring (Composition Root)
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
GetSponsorsUseCase,
|
||||
GetSponsorsPresenter,
|
||||
{
|
||||
provide: USE_CASE_OUTPUT_PORT,
|
||||
useExisting: GetSponsorsPresenter,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class SponsorsModule {}
|
||||
|
||||
Rules:
|
||||
• The use case depends only on the OutputPort interface
|
||||
• The presenter is bound as the OutputPort implementation
|
||||
• process.env is not used inside the use case
|
||||
|
||||
~
|
||||
|
||||
5. Explicitly Forbidden
|
||||
|
||||
❌ DTOs in use cases
|
||||
❌ Domain objects returned directly to the API
|
||||
❌ Output ports used as data structures
|
||||
❌ present() returning a value
|
||||
❌ Input data named InputPort
|
||||
❌ Mapping logic inside use cases
|
||||
❌ Environment access inside the core
|
||||
|
||||
~
|
||||
|
||||
Do / Don’t (Boundary Examples)
|
||||
|
||||
✅ DO: Keep pages/components consuming ViewModels returned by website services (DTOs stop at the service boundary), e.g. [LeagueAdminSchedulePage()](apps/website/app/leagues/[id]/schedule/admin/page.tsx:12).
|
||||
✅ DO: Keep controllers/services thin and delegating, e.g. [LeagueController.createLeagueSeasonScheduleRace()](apps/api/src/domain/league/LeagueController.ts:291).
|
||||
❌ DON’T: Put business rules in the API layer; rules belong in `./core` use cases/entities/value objects, e.g. [CreateLeagueSeasonScheduleRaceUseCase.execute()](core/racing/application/use-cases/CreateLeagueSeasonScheduleRaceUseCase.ts:38).
|
||||
|
||||
~
|
||||
|
||||
6. Optional Extensions
|
||||
|
||||
Custom Output Ports
|
||||
|
||||
Only introduce a dedicated OutputPort interface if:
|
||||
• multiple presentation paths exist
|
||||
• streaming or progress updates are required
|
||||
• more than one output method is needed
|
||||
|
||||
interface ComplexOutputPort {
|
||||
presentSuccess(...)
|
||||
presentFailure(...)
|
||||
}
|
||||
|
||||
|
||||
~
|
||||
|
||||
Input Port Interfaces
|
||||
|
||||
Only introduce an explicit InputPort interface if:
|
||||
• multiple implementations of the same use case exist
|
||||
• feature flags or A/B variants are required
|
||||
• the use case itself must be substituted
|
||||
|
||||
Otherwise:
|
||||
|
||||
The use case class itself is the input port.
|
||||
|
||||
~
|
||||
|
||||
7. Key Rules (Memorize These)
|
||||
|
||||
Use cases answer what.
|
||||
Presenters answer how.
|
||||
|
||||
Ports have behavior.
|
||||
Data does not.
|
||||
|
||||
The core produces truth.
|
||||
The API interprets it.
|
||||
|
||||
~
|
||||
|
||||
TL;DR
|
||||
• Use cases are injected via DI
|
||||
• execute() is the Input Port
|
||||
• Outputs flow only through Output Ports
|
||||
• Results are business models, not DTOs
|
||||
• Interfaces exist only for behavior variability
|
||||
|
||||
### 🚨 CRITICAL CLEAN ARCHITECTURE CORRECTION
|
||||
|
||||
**The examples in this document (sections 2, 3, and the Payments Example) demonstrate the WRONG pattern that violates Clean Architecture.**
|
||||
|
||||
#### The Fundamental Problem
|
||||
|
||||
The current architecture shows use cases **calling presenters directly**:
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - This violates Clean Architecture
|
||||
this.output.present({ sponsors })
|
||||
```
|
||||
|
||||
**This is architecturally incorrect.** Use cases must **never** know about presenters or call `.present()`.
|
||||
|
||||
#### The Correct Clean Architecture Pattern
|
||||
|
||||
**Use cases return Results. Controllers wire them to presenters.**
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Use case returns data
|
||||
@Injectable()
|
||||
export class GetSponsorsUseCase {
|
||||
constructor(private readonly sponsorRepository: ISponsorRepository) {}
|
||||
|
||||
async execute(): Promise<Result<GetSponsorsResult, ApplicationError>> {
|
||||
const sponsors = await this.sponsorRepository.findAll()
|
||||
return Result.ok({ sponsors })
|
||||
// NO .present() call!
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ CORRECT - Controller handles wiring
|
||||
@Controller('/sponsors')
|
||||
export class SponsorsController {
|
||||
constructor(
|
||||
private readonly useCase: GetSponsorsUseCase,
|
||||
private readonly presenter: GetSponsorsPresenter,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async getSponsors() {
|
||||
const result = await this.useCase.execute()
|
||||
|
||||
if (result.isErr()) {
|
||||
throw mapApplicationError(result.unwrapErr())
|
||||
}
|
||||
|
||||
this.presenter.present(result.value)
|
||||
return this.presenter.getViewModel()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Why This Matters
|
||||
|
||||
1. **Dependency Rule**: Inner layers (use cases) cannot depend on outer layers (presenters)
|
||||
2. **Testability**: Use cases can be tested without mocking presenters
|
||||
3. **Flexibility**: Same use case can work with different presenters
|
||||
4. **Separation of Concerns**: Use cases do business logic, presenters do transformation
|
||||
|
||||
#### What Must Be Fixed
|
||||
|
||||
**All use cases in the codebase must be updated to:**
|
||||
1. **Remove** the `output: UseCaseOutputPort<T>` constructor parameter
|
||||
2. **Return** `Result<T, E>` directly from `execute()`
|
||||
3. **Remove** all `this.output.present()` calls
|
||||
|
||||
**All controllers must be updated to:**
|
||||
1. **Call** the use case and get the Result
|
||||
2. **Pass** `result.value` to the presenter's `.present()` method
|
||||
3. **Return** the presenter's `.getViewModel()`
|
||||
|
||||
This is the **single source of truth** for correct Clean Architecture in this project.
|
||||
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.**
|
||||
51
docs/architecture/website/BLOCKERS.md
Normal file
51
docs/architecture/website/BLOCKERS.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Blockers (Website UX)
|
||||
|
||||
This document defines **Blockers** as UX-only prevention mechanisms in the website.
|
||||
|
||||
Shared contract: [`docs/architecture/shared/BLOCKERS_AND_GUARDS.md`](docs/architecture/shared/BLOCKERS_AND_GUARDS.md:1)
|
||||
|
||||
## 1) Definition
|
||||
|
||||
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.
|
||||
|
||||
## 2) Responsibilities
|
||||
|
||||
Blockers MAY:
|
||||
|
||||
- prevent multiple submissions
|
||||
- disable actions temporarily
|
||||
- debounce or throttle interactions
|
||||
- hide or disable UI elements
|
||||
- prevent navigation under certain conditions
|
||||
|
||||
Blockers MUST:
|
||||
|
||||
- be reversible
|
||||
- be local to the website
|
||||
- be treated as best-effort helpers
|
||||
|
||||
## 3) Restrictions
|
||||
|
||||
Blockers MUST NOT:
|
||||
|
||||
- enforce security
|
||||
- claim authorization
|
||||
- block access permanently
|
||||
- replace API Guards
|
||||
- make assumptions about backend state
|
||||
|
||||
## 4) Common Blockers
|
||||
|
||||
- SubmitBlocker
|
||||
- ThrottleBlocker
|
||||
- NavigationBlocker
|
||||
- FeatureBlocker
|
||||
|
||||
## 5) Canonical placement
|
||||
|
||||
- `apps/website/lib/blockers/**`
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
Blockers & Guards
|
||||
|
||||
This document defines clear, non-overlapping responsibilities for Blockers (frontend) and Guards (backend).
|
||||
The goal is to prevent semantic drift, security confusion, and inconsistent implementations.
|
||||
|
||||
⸻
|
||||
|
||||
Core Principle
|
||||
|
||||
Guards enforce. Blockers prevent.
|
||||
• Guards protect the system.
|
||||
• Blockers protect the UX.
|
||||
|
||||
There are no exceptions to this rule.
|
||||
|
||||
⸻
|
||||
|
||||
Backend — Guards (NestJS)
|
||||
|
||||
Definition
|
||||
|
||||
A Guard is a backend mechanism that enforces access or execution rules.
|
||||
If a Guard denies execution, the request does not reach the application logic.
|
||||
|
||||
In NestJS, Guards implement CanActivate.
|
||||
|
||||
⸻
|
||||
|
||||
Responsibilities
|
||||
|
||||
Guards MAY:
|
||||
• block requests entirely
|
||||
• return HTTP errors (401, 403, 429)
|
||||
• enforce authentication and authorization
|
||||
• enforce rate limits
|
||||
• enforce feature availability
|
||||
• protect against abuse and attacks
|
||||
|
||||
Guards MUST:
|
||||
• be deterministic
|
||||
• be authoritative
|
||||
• be security-relevant
|
||||
|
||||
⸻
|
||||
|
||||
Restrictions
|
||||
|
||||
Guards MUST NOT:
|
||||
• depend on frontend state
|
||||
• contain UI logic
|
||||
• attempt to improve UX
|
||||
• assume the client behaved correctly
|
||||
|
||||
⸻
|
||||
|
||||
Common Backend Guards
|
||||
• AuthGuard
|
||||
• RolesGuard
|
||||
• PermissionsGuard
|
||||
• ThrottlerGuard (NestJS)
|
||||
• RateLimitGuard
|
||||
• CsrfGuard
|
||||
• FeatureFlagGuard
|
||||
|
||||
⸻
|
||||
|
||||
Summary (Backend)
|
||||
• Guards decide
|
||||
• Guards enforce
|
||||
• Guards secure the system
|
||||
|
||||
⸻
|
||||
|
||||
Frontend — Blockers
|
||||
|
||||
Definition
|
||||
|
||||
A Blocker is a frontend mechanism that prevents an action from being executed.
|
||||
Blockers exist solely to improve UX and reduce unnecessary requests.
|
||||
|
||||
Blockers are not security mechanisms.
|
||||
|
||||
⸻
|
||||
|
||||
Responsibilities
|
||||
|
||||
Blockers MAY:
|
||||
• prevent multiple submissions
|
||||
• disable actions temporarily
|
||||
• debounce or throttle interactions
|
||||
• hide or disable UI elements
|
||||
• prevent navigation under certain conditions
|
||||
|
||||
Blockers MUST:
|
||||
• be reversible
|
||||
• be local to the frontend
|
||||
• be treated as best-effort helpers
|
||||
|
||||
⸻
|
||||
|
||||
Restrictions
|
||||
|
||||
Blockers MUST NOT:
|
||||
• enforce security
|
||||
• claim authorization
|
||||
• block access permanently
|
||||
• replace backend Guards
|
||||
• make assumptions about backend state
|
||||
|
||||
⸻
|
||||
|
||||
Common Frontend Blockers
|
||||
• SubmitBlocker
|
||||
• AuthBlocker
|
||||
• RoleBlocker
|
||||
• ThrottleBlocker
|
||||
• NavigationBlocker
|
||||
• FeatureBlocker
|
||||
|
||||
⸻
|
||||
|
||||
Summary (Frontend)
|
||||
• Blockers prevent execution
|
||||
• Blockers improve UX
|
||||
• Blockers reduce mistakes and load
|
||||
|
||||
⸻
|
||||
|
||||
Clear Separation
|
||||
|
||||
Aspect Blocker (Frontend) Guard (Backend)
|
||||
Purpose Prevent execution Enforce rules
|
||||
Security ❌ No ✅ Yes
|
||||
Authority ❌ Best-effort ✅ Final
|
||||
Reversible ✅ Yes ❌ No
|
||||
Failure effect UI feedback HTTP error
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
Naming Rules (Hard)
|
||||
• Frontend uses *Blocker
|
||||
• Backend uses *Guard
|
||||
• Never mix the terms
|
||||
• Never implement Guards in the frontend
|
||||
• Never implement Blockers in the backend
|
||||
|
||||
⸻
|
||||
|
||||
Final Rule
|
||||
|
||||
If it must be enforced, it is a Guard.
|
||||
|
||||
If it only prevents UX mistakes, it is a Blocker.
|
||||
@@ -50,11 +50,10 @@ Blockers exist to prevent UX mistakes.
|
||||
- Blockers may reduce unnecessary requests.
|
||||
- The API still enforces rules.
|
||||
|
||||
See [`BLOCKER_GUARDS.md`](docs/architecture/website/BLOCKER_GUARDS.md:1).
|
||||
See [`docs/architecture/shared/BLOCKERS_AND_GUARDS.md`](docs/architecture/shared/BLOCKERS_AND_GUARDS.md:1) and [`docs/architecture/website/BLOCKERS.md`](docs/architecture/website/BLOCKERS.md:1).
|
||||
|
||||
## 6) Canonical placement in this repo
|
||||
|
||||
- `apps/website/lib/blockers/**`
|
||||
- `apps/website/lib/hooks/**`
|
||||
- `apps/website/lib/command-models/**`
|
||||
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
# Login Flow State Machine Architecture
|
||||
# Login Flow State Machine (Strict)
|
||||
|
||||
## Problem
|
||||
The current login page has unpredictable behavior due to:
|
||||
- Multiple useEffect runs with different session states
|
||||
- Race conditions between session loading and redirect logic
|
||||
- Client-side redirects that interfere with test expectations
|
||||
This document defines the canonical, deterministic login flow controller for the website.
|
||||
|
||||
## Solution: State Machine Pattern
|
||||
Authoritative website contract:
|
||||
|
||||
### State Definitions
|
||||
- [`docs/architecture/website/WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:1)
|
||||
|
||||
## 1) Core rule
|
||||
|
||||
Login flow logic MUST be deterministic.
|
||||
|
||||
The same inputs MUST produce the same state and the same next action.
|
||||
|
||||
## 2) State machine definition (strict)
|
||||
|
||||
### 2.1 State definitions
|
||||
|
||||
```typescript
|
||||
enum LoginState {
|
||||
@@ -19,7 +25,7 @@ enum LoginState {
|
||||
}
|
||||
```
|
||||
|
||||
### State Transition Table
|
||||
### 2.2 State transition table
|
||||
|
||||
| Current State | Session | ReturnTo | Next State | Action |
|
||||
|---------------|---------|----------|------------|--------|
|
||||
@@ -29,7 +35,7 @@ enum LoginState {
|
||||
| UNAUTHENTICATED | exists | any | POST_AUTH_REDIRECT | Redirect to returnTo |
|
||||
| AUTHENTICATED_WITHOUT_PERMISSIONS | exists | any | POST_AUTH_REDIRECT | Redirect to returnTo |
|
||||
|
||||
### Class-Based Controller
|
||||
### 2.3 Class-based controller
|
||||
|
||||
```typescript
|
||||
class LoginFlowController {
|
||||
@@ -57,7 +63,7 @@ class LoginFlowController {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
// Pure function - returns action, doesn't execute
|
||||
// Pure function - returns action, does not execute
|
||||
getNextAction(): LoginAction {
|
||||
switch (this.state) {
|
||||
case LoginState.UNAUTHENTICATED:
|
||||
@@ -71,7 +77,7 @@ class LoginFlowController {
|
||||
}
|
||||
}
|
||||
|
||||
// Called after authentication
|
||||
// Transition called after authentication
|
||||
transitionToPostAuth(): void {
|
||||
if (this.session) {
|
||||
this.state = LoginState.POST_AUTH_REDIRECT;
|
||||
@@ -80,15 +86,14 @@ class LoginFlowController {
|
||||
}
|
||||
```
|
||||
|
||||
### Benefits
|
||||
## 3) Non-negotiable rules
|
||||
|
||||
1. **Predictable**: Same inputs always produce same outputs
|
||||
2. **Testable**: Can test each state transition independently
|
||||
3. **No Race Conditions**: State determined once at construction
|
||||
4. **Clear Intent**: Each state has a single purpose
|
||||
5. **Maintainable**: Easy to add new states or modify transitions
|
||||
1. The controller MUST be constructed from explicit inputs only.
|
||||
2. The controller MUST NOT perform side effects.
|
||||
3. Side effects (routing) MUST be executed outside the controller.
|
||||
4. The controller MUST be unit-tested per transition.
|
||||
|
||||
### Usage in Login Page
|
||||
## 4) Usage in login page (example)
|
||||
|
||||
```typescript
|
||||
export default function LoginPage() {
|
||||
@@ -129,4 +134,4 @@ export default function LoginPage() {
|
||||
}
|
||||
```
|
||||
|
||||
This eliminates all the unpredictable behavior and makes the flow testable and maintainable.
|
||||
This pattern ensures deterministic behavior and makes the flow testable.
|
||||
|
||||
46
docs/architecture/website/WEBSITE_AUTH_FLOW.md
Normal file
46
docs/architecture/website/WEBSITE_AUTH_FLOW.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Authentication UX Flow (Website)
|
||||
|
||||
This document defines how the website handles authentication from a UX perspective.
|
||||
|
||||
Shared contract:
|
||||
|
||||
- [`docs/architecture/shared/AUTH_CONTRACT.md`](docs/architecture/shared/AUTH_CONTRACT.md:1)
|
||||
|
||||
Authoritative website contract:
|
||||
|
||||
- [`docs/architecture/website/WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:1)
|
||||
|
||||
## 1) Website role (strict)
|
||||
|
||||
The website:
|
||||
|
||||
- redirects unauthenticated users to login
|
||||
- hides or disables UI based on best-effort session knowledge
|
||||
|
||||
The website does not enforce security.
|
||||
|
||||
## 2) Canonical website flow
|
||||
|
||||
```text
|
||||
Request
|
||||
↓
|
||||
Website routing
|
||||
↓
|
||||
API requests with credentials
|
||||
↓
|
||||
API enforces authentication and authorization
|
||||
↓
|
||||
Website renders result or redirects
|
||||
```
|
||||
|
||||
## 3) Non-negotiable rules
|
||||
|
||||
1. The website MUST NOT claim authorization.
|
||||
2. The website MUST NOT trust client state for enforcement.
|
||||
3. Every write still relies on the API to accept or reject.
|
||||
|
||||
Related:
|
||||
|
||||
- Website blockers: [`docs/architecture/website/BLOCKERS.md`](docs/architecture/website/BLOCKERS.md:1)
|
||||
- Client state rules: [`docs/architecture/website/CLIENT_STATE.md`](docs/architecture/website/CLIENT_STATE.md:1)
|
||||
|
||||
@@ -189,7 +189,7 @@ See [`FORM_SUBMISSION.md`](docs/architecture/website/FORM_SUBMISSION.md:1).
|
||||
- The website MUST NOT enforce security.
|
||||
- The API enforces authentication and authorization.
|
||||
|
||||
See [`BLOCKER_GUARDS.md`](docs/architecture/website/BLOCKER_GUARDS.md:1).
|
||||
See [`docs/architecture/shared/BLOCKERS_AND_GUARDS.md`](docs/architecture/shared/BLOCKERS_AND_GUARDS.md:1) and [`docs/architecture/website/BLOCKERS.md`](docs/architecture/website/BLOCKERS.md:1).
|
||||
|
||||
## 7.1) Client state (strict)
|
||||
|
||||
|
||||
65
docs/architecture/website/WEBSITE_DATA_FLOW.md
Normal file
65
docs/architecture/website/WEBSITE_DATA_FLOW.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Website Data Flow (Strict)
|
||||
|
||||
This document defines the **apps/website** data flow.
|
||||
|
||||
Authoritative contract: [`docs/architecture/website/WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:1).
|
||||
|
||||
Website scope:
|
||||
|
||||
- `apps/website/**`
|
||||
|
||||
## 1) Website role
|
||||
|
||||
The website is a **delivery layer**.
|
||||
|
||||
It renders truth from the API and forwards user intent to the API.
|
||||
|
||||
## 2) Read flow
|
||||
|
||||
```text
|
||||
RSC page.tsx
|
||||
↓
|
||||
PageQuery
|
||||
↓
|
||||
API client (infra)
|
||||
↓
|
||||
API Transport DTO
|
||||
↓
|
||||
Page DTO
|
||||
↓
|
||||
Presenter (client)
|
||||
↓
|
||||
ViewModel (optional)
|
||||
↓
|
||||
Presenter (client)
|
||||
↓
|
||||
ViewData
|
||||
↓
|
||||
Template
|
||||
```
|
||||
|
||||
## 3) Write flow
|
||||
|
||||
All writes enter through **Server Actions**.
|
||||
|
||||
```text
|
||||
User intent
|
||||
↓
|
||||
Server Action
|
||||
↓
|
||||
Command Model / Request DTO
|
||||
↓
|
||||
API
|
||||
↓
|
||||
Revalidation
|
||||
↓
|
||||
RSC reload
|
||||
```
|
||||
|
||||
## 4) Non-negotiable rules
|
||||
|
||||
1. Templates accept ViewData only.
|
||||
2. Page Queries do not format.
|
||||
3. Presenters do not call the API.
|
||||
4. Client state is UI-only.
|
||||
|
||||
50
docs/architecture/website/WEBSITE_FILE_STRUCTURE.md
Normal file
50
docs/architecture/website/WEBSITE_FILE_STRUCTURE.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Website File Structure (Strict)
|
||||
|
||||
This document defines the canonical **physical** structure for `apps/website/**`.
|
||||
|
||||
It describes where code lives, not the full behavioral rules.
|
||||
|
||||
Authoritative contract:
|
||||
|
||||
- [`docs/architecture/website/WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:1)
|
||||
|
||||
## 1) High-level layout
|
||||
|
||||
```text
|
||||
apps/website/
|
||||
app/ Next.js routes (RSC pages, layouts, server actions)
|
||||
templates/ template components (ViewData only)
|
||||
lib/ website code (clients, services, view-models, etc.)
|
||||
```
|
||||
|
||||
## 2) `apps/website/app/` (routing)
|
||||
|
||||
Routes are implemented via Next.js App Router.
|
||||
|
||||
Rules:
|
||||
|
||||
- server `page.tsx` does composition only
|
||||
- templates are pure
|
||||
- writes enter via server actions
|
||||
|
||||
See [`docs/architecture/website/WEBSITE_RSC_PRESENTATION.md`](docs/architecture/website/WEBSITE_RSC_PRESENTATION.md:1).
|
||||
|
||||
## 3) `apps/website/lib/` (website internals)
|
||||
|
||||
Canonical folders (existing in this repo):
|
||||
|
||||
```text
|
||||
apps/website/lib/
|
||||
api/ API clients
|
||||
infrastructure/ technical concerns
|
||||
services/ UI orchestration (read-only and write orchestration)
|
||||
page-queries/ server composition
|
||||
types/ API transport DTOs
|
||||
view-models/ client-only classes
|
||||
display-objects/ deterministic formatting helpers
|
||||
command-models/ transient form models
|
||||
blockers/ UX-only prevention
|
||||
hooks/ React-only helpers
|
||||
di/ client-first DI integration
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user