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?”
|
- Authorization answers: “Is this actor allowed to do it?”
|
||||||
|
|
||||||
Related:
|
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.
|
Use this sparingly and intentionally.
|
||||||
|
|
||||||
### 6.3 Feature availability interaction
|
### 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:
|
- A super-admin UI can manage:
|
||||||
- global roles (owner/admin)
|
- global roles (owner/admin)
|
||||||
- scoped roles (league_owner/admin/steward, sponsor_owner/admin, team_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:
|
CQRS Light is a structural rule inside Core.
|
||||||
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)
|
|
||||||
|
|
||||||
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
|
• Event sourcing complexity
|
||||||
• Premature optimization
|
• 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
|
This document defines the strict rules for Core Use Cases.
|
||||||
according to Clean Architecture, in a NestJS-based system.
|
|
||||||
|
|
||||||
The goal is:
|
Scope:
|
||||||
• strict separation of concerns
|
|
||||||
• correct terminology (no fake "ports")
|
|
||||||
• minimal abstractions
|
|
||||||
• long-term consistency
|
|
||||||
|
|
||||||
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
|
## 1) Definition
|
||||||
• 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
|
|
||||||
|
|
||||||
The public execute() method is the input port.
|
A Use Case represents one business intent.
|
||||||
|
|
||||||
~
|
It answers:
|
||||||
|
|
||||||
Input
|
- what the system does
|
||||||
• Pure data
|
|
||||||
• Not a port
|
|
||||||
• Not an interface
|
|
||||||
• May be omitted if the use case has no parameters
|
|
||||||
|
|
||||||
type GetSponsorsInput = {
|
## 2) Non-negotiable rules
|
||||||
leagueId: LeagueId
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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
|
Inputs:
|
||||||
• The business outcome of a use case
|
|
||||||
• May contain Entities and Value Objects
|
|
||||||
• Not a DTO
|
|
||||||
• Never leaves the core directly
|
|
||||||
|
|
||||||
type GetSponsorsResult = {
|
- plain data and/or domain types
|
||||||
sponsors: Sponsor[]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
|
||||||
~
|
- a `Result` containing plain data and/or domain types
|
||||||
|
|
||||||
Output Port
|
Rule:
|
||||||
• A behavioral boundary
|
|
||||||
• Defines how the core communicates outward
|
|
||||||
• Never a data structure
|
|
||||||
• Lives in the Application Layer
|
|
||||||
|
|
||||||
export interface UseCaseOutputPort<T> {
|
- mapping to and from HTTP DTOs happens in the API, not in the Core.
|
||||||
present(data: T): void
|
|
||||||
}
|
|
||||||
|
|
||||||
|
See API wiring: [`docs/architecture/api/USE_CASE_WIRING.md`](docs/architecture/api/USE_CASE_WIRING.md:1)
|
||||||
|
|
||||||
~
|
## 4) Ports
|
||||||
|
|
||||||
Presenter
|
Use Cases depend on ports for IO.
|
||||||
• 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rules:
|
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
|
See [`docs/architecture/core/CQRS.md`](docs/architecture/core/CQRS.md:1).
|
||||||
@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 }) // ❌ 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:
|
It does not contain app-specific rules.
|
||||||
• 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
|
|
||||||
|
|
||||||
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
|
Dependencies point inward.
|
||||||
Application → Use Cases + Application Services
|
|
||||||
Adapters → API, Persistence, External Systems
|
|
||||||
Frontend → UI, View Models, UX logic
|
|
||||||
|
|
||||||
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
|
If data crosses a boundary, it is mapped.
|
||||||
• 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.
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
• CreateLeague
|
|
||||||
• ApproveSponsorship
|
|
||||||
• CompleteDriverOnboarding
|
|
||||||
|
|
||||||
Rules
|
- HTTP Request DTO is mapped to Core input.
|
||||||
• A Use Case:
|
- Core result is mapped to HTTP Response DTO.
|
||||||
• 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
|
|
||||||
|
|
||||||
Structure
|
## 3) Ownership rule
|
||||||
|
|
||||||
core/racing/application/use-cases/
|
Each layer owns its data shapes.
|
||||||
└─ CreateLeagueUseCase.ts
|
|
||||||
|
|
||||||
Example
|
- Core owns domain and application models.
|
||||||
|
- API owns HTTP DTOs.
|
||||||
|
- Website owns ViewData and ViewModels.
|
||||||
|
|
||||||
export class CreateLeagueUseCase {
|
No layer re-exports another layer’s models as-is across a boundary.
|
||||||
constructor(
|
|
||||||
private readonly leagueRepository: LeagueRepositoryPort,
|
|
||||||
private readonly output: CreateLeagueOutputPort
|
|
||||||
) {}
|
|
||||||
|
|
||||||
execute(input: CreateLeagueInputPort): void {
|
## 4) Non-negotiable rules
|
||||||
// business rules & invariants
|
|
||||||
|
|
||||||
const league = League.create(input.name, input.maxMembers);
|
1. Core contains business truth.
|
||||||
this.leagueRepository.save(league);
|
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:
|
This document defines the shared, cross-app system for enabling and disabling capabilities.
|
||||||
- API endpoints
|
|
||||||
- Website links/navigation
|
|
||||||
- Website components
|
|
||||||
|
|
||||||
It is designed to support:
|
Feature availability is not authorization.
|
||||||
- test mode
|
|
||||||
- maintenance mode
|
|
||||||
- disabling features due to risk/issues
|
|
||||||
- coming soon features
|
|
||||||
- future super admin flag management
|
|
||||||
|
|
||||||
It is aligned with the hard separation of responsibilities in `Blockers & Guards`:
|
Shared contract:
|
||||||
- Frontend uses Blockers (UX best-effort)
|
|
||||||
- Backend uses Guards (authoritative enforcement)
|
|
||||||
|
|
||||||
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 (non-negotiable)
|
||||||
|
|
||||||
## 1) Core Principle
|
|
||||||
|
|
||||||
Availability is decided once, then applied in multiple places.
|
Availability is decided once, then applied in multiple places.
|
||||||
|
|
||||||
- Backend Guards enforce availability for correctness and security.
|
- API Guards enforce availability.
|
||||||
- Frontend Blockers reflect availability for UX, but must never be relied on for enforcement.
|
- Website Blockers reflect availability for UX.
|
||||||
|
|
||||||
If it must be enforced, it is a Guard.
|
## 2) Capability model (strict)
|
||||||
If it only improves UX, it is a Blocker.
|
|
||||||
|
|
||||||
---
|
Inputs to evaluation:
|
||||||
|
|
||||||
## 2) Definitions (Canonical Vocabulary)
|
- operational mode (normal, maintenance, test)
|
||||||
|
- capability key (stable string)
|
||||||
### 2.1 Operational Mode (system-level)
|
- action type (view, mutate)
|
||||||
A small, global state representing operational posture.
|
- actor context (anonymous, authenticated)
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
Outputs:
|
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:
|
- allow or deny
|
||||||
- API Guard enforcement
|
- a public reason (maintenance, disabled, coming_soon, hidden, not_configured)
|
||||||
- Website navigation visibility
|
|
||||||
- Website component rendering/disablement
|
|
||||||
|
|
||||||
### 3.2 Precedence (where values come from)
|
## 3) Non-negotiable rules
|
||||||
To avoid “mystery behavior”, use strict precedence:
|
|
||||||
|
|
||||||
1. runtime overrides (highest priority)
|
1. Default is deny unless explicitly enabled.
|
||||||
2. build-time environment configuration
|
2. The API is authoritative.
|
||||||
3. code defaults (lowest priority, should be safe: hidden/disabled)
|
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.
|
- Blockers may reduce unnecessary requests.
|
||||||
- The API still enforces rules.
|
- 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
|
## 6) Canonical placement in this repo
|
||||||
|
|
||||||
- `apps/website/lib/blockers/**`
|
- `apps/website/lib/blockers/**`
|
||||||
- `apps/website/lib/hooks/**`
|
- `apps/website/lib/hooks/**`
|
||||||
- `apps/website/lib/command-models/**`
|
- `apps/website/lib/command-models/**`
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
# Login Flow State Machine Architecture
|
# Login Flow State Machine (Strict)
|
||||||
|
|
||||||
## Problem
|
This document defines the canonical, deterministic login flow controller for the website.
|
||||||
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
|
|
||||||
|
|
||||||
## 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
|
```typescript
|
||||||
enum LoginState {
|
enum LoginState {
|
||||||
@@ -19,7 +25,7 @@ enum LoginState {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### State Transition Table
|
### 2.2 State transition table
|
||||||
|
|
||||||
| Current State | Session | ReturnTo | Next State | Action |
|
| Current State | Session | ReturnTo | Next State | Action |
|
||||||
|---------------|---------|----------|------------|--------|
|
|---------------|---------|----------|------------|--------|
|
||||||
@@ -29,7 +35,7 @@ enum LoginState {
|
|||||||
| UNAUTHENTICATED | exists | any | POST_AUTH_REDIRECT | Redirect to returnTo |
|
| UNAUTHENTICATED | exists | any | POST_AUTH_REDIRECT | Redirect to returnTo |
|
||||||
| AUTHENTICATED_WITHOUT_PERMISSIONS | 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
|
```typescript
|
||||||
class LoginFlowController {
|
class LoginFlowController {
|
||||||
@@ -57,7 +63,7 @@ class LoginFlowController {
|
|||||||
return this.state;
|
return this.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pure function - returns action, doesn't execute
|
// Pure function - returns action, does not execute
|
||||||
getNextAction(): LoginAction {
|
getNextAction(): LoginAction {
|
||||||
switch (this.state) {
|
switch (this.state) {
|
||||||
case LoginState.UNAUTHENTICATED:
|
case LoginState.UNAUTHENTICATED:
|
||||||
@@ -71,7 +77,7 @@ class LoginFlowController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called after authentication
|
// Transition called after authentication
|
||||||
transitionToPostAuth(): void {
|
transitionToPostAuth(): void {
|
||||||
if (this.session) {
|
if (this.session) {
|
||||||
this.state = LoginState.POST_AUTH_REDIRECT;
|
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
|
1. The controller MUST be constructed from explicit inputs only.
|
||||||
2. **Testable**: Can test each state transition independently
|
2. The controller MUST NOT perform side effects.
|
||||||
3. **No Race Conditions**: State determined once at construction
|
3. Side effects (routing) MUST be executed outside the controller.
|
||||||
4. **Clear Intent**: Each state has a single purpose
|
4. The controller MUST be unit-tested per transition.
|
||||||
5. **Maintainable**: Easy to add new states or modify transitions
|
|
||||||
|
|
||||||
### Usage in Login Page
|
## 4) Usage in login page (example)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export default function LoginPage() {
|
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 website MUST NOT enforce security.
|
||||||
- The API enforces authentication and authorization.
|
- 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)
|
## 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