diff --git a/docs/index.md b/docs/index.md index 6d4c9219..e6eb3337 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,4 +12,4 @@ Welcome to the documentation for The Biergarten App. - **[Architecture](web/architecture.md)** — System design and components - **[Website](web/getting-started.md)** — Backend and frontend documentation -- **[Pipeline](pipeline/README.md)** — Data pipeline and ETL processes +- **[Pipeline](pipeline/README.md)** — Data generation pipeline diff --git a/docs/pipeline/ETHICS-AND-KNOWN-ISSUES.md b/docs/pipeline/ETHICS-AND-KNOWN-ISSUES.md index 92ff1c84..ebb649e9 100644 --- a/docs/pipeline/ETHICS-AND-KNOWN-ISSUES.md +++ b/docs/pipeline/ETHICS-AND-KNOWN-ISSUES.md @@ -306,13 +306,13 @@ consistent with the rest of the dataset: fixture data for a proof-of-concept, not a source of truth. For brewery addresses specifically (not user addresses, which carry no street -address at all), `address_line1`/`postal_code` *are* looked up from a real +address at all), `address_line1`/`postal_code` _are_ looked up from a real address registry — `NominatimAddressService` reverse-geocodes the synthetic -coordinate pair against the live OpenStreetMap/Nominatim database, so the -street address is a genuine nearby address, just not the address of an actual -brewery. This lookup only happens for non-`--mocked` runs; `--mocked` runs use -`MockAddressService`'s fixed placeholder address instead, which is not looked -up from any real registry. +coordinate pair against the live OpenStreetMap/Nominatim database, so the street +address is a genuine nearby address, just not the address of an actual brewery. +This lookup only happens for non-`--mocked` runs; `--mocked` runs use +`MockAddressService`'s fixed placeholder address instead, which is not looked up +from any real registry. --- diff --git a/docs/pipeline/README.md b/docs/pipeline/README.md index 9fa7db6d..f926eb63 100644 --- a/docs/pipeline/README.md +++ b/docs/pipeline/README.md @@ -110,27 +110,27 @@ Each run writes a fresh dated SQLite file such as #### CLI flags -| Flag | Purpose | -| ------------------ | ------------------------------------------------------------------------------------------------------------ | -| `--mocked` | Deterministic mock generator, no model required. | -| `--model, -m` | Path to a GGUF file. Selects the local Llama.cpp generator. | +| Flag | Purpose | +| ------------------ | ---------------------------------------------------------------------------------------------------------------- | +| `--mocked` | Deterministic mock generator, no model required. | +| `--model, -m` | Path to a GGUF file. Selects the local Llama.cpp generator. | | `--openai` | Use the OpenAI API (Chat Completions) for brewery/user data. Requires the `OPENAI_API_KEY` environment variable. | -| `--openai-model` | OpenAI model ID used when `--openai` is set. Default: `gpt-4o-mini`. | -| `--prompt-dir` | Directory containing prompt files (for example, `BREWERY_GENERATION.md`). Required unless `--mocked` is set. | -| `--output, -o` | Directory for generated SQLite artifacts. Default: `output`. | -| `--log-path` | Path for application logs. Default: `pipeline.log`. | -| `--location-count` | Number of cities to sample from `locations.json` per run. Default: `10`. | -| `--temperature` | Sampling temperature. Default: `1.0`. | -| `--top-p` | Nucleus sampling. Default: `0.95`. | -| `--top-k` | Top-k sampling. Default: `64`. | -| `--n-ctx` | Context window size. Default: `8192`. | -| `--seed` | Random seed. Default: `-1` (random at runtime). | -| `--n-gpu-layers` | Number of model layers to offload to GPU. Default: `0`. | -| `--help, -h` | Print usage and exit. | +| `--openai-model` | OpenAI model ID used when `--openai` is set. Default: `gpt-4o-mini`. | +| `--prompt-dir` | Directory containing prompt files (for example, `BREWERY_GENERATION.md`). Required unless `--mocked` is set. | +| `--output, -o` | Directory for generated SQLite artifacts. Default: `output`. | +| `--log-path` | Path for application logs. Default: `pipeline.log`. | +| `--location-count` | Number of cities to sample from `locations.json` per run. Default: `10`. | +| `--temperature` | Sampling temperature. Default: `1.0`. | +| `--top-p` | Nucleus sampling. Default: `0.95`. | +| `--top-k` | Top-k sampling. Default: `64`. | +| `--n-ctx` | Context window size. Default: `8192`. | +| `--seed` | Random seed. Default: `-1` (random at runtime). | +| `--n-gpu-layers` | Number of model layers to offload to GPU. Default: `0`. | +| `--help, -h` | Print usage and exit. | -`--mocked`, `--model`, and `--openai` are mutually exclusive. Omitting all -three exits with an error before the pipeline starts. Sampling flags are -ignored unless `--model` is set (Llama). +`--mocked`, `--model`, and `--openai` are mutually exclusive. Omitting all three +exits with an error before the pipeline starts. Sampling flags are ignored +unless `--model` is set (Llama). The post-build step copies `prompts/` into `build/prompts/`. Rebuild after editing any prompt file. @@ -238,8 +238,8 @@ to match your run. | Sample | `BiergartenPipelineOrchestrator::QueryCitiesWithCountries()` samples `--location-count` locations per run (default `10`). | | Enrich | `WikipediaEnrichmentService` fetches brewing and beer-related context. Keeps going when a lookup fails. `--mocked` runs use `MockEnrichmentService` instead and skip Wikipedia entirely. | | Generate Users | `GenerateUsers()` samples a persona and a forename/surname pair per enriched city (skipping countries with no name data), then `MockGenerator` or `LlamaGenerator` produces a username, bio, and activity weight around the sampled name. | -| Generate Breweries | `MockGenerator`, `LlamaGenerator`, or `OpenAIGenerator` produces brewery names and descriptions in English and the local language, then `IAddressService::ReverseGeocode()` resolves a street address for the brewery's jittered coordinates. | -| Store | `SqliteExportService` writes each successful user and brewery into a fresh dated `.sqlite` database with normalized `cities`, `breweries`, `brewery_addresses`, `users`, and `user_addresses` tables. | +| Generate Breweries | `MockGenerator`, `LlamaGenerator`, or `OpenAIGenerator` produces brewery names and descriptions in English and the local language, then `IAddressService::ReverseGeocode()` resolves a street address for the brewery's jittered coordinates. | +| Store | `SqliteExportService` writes each successful user and brewery into a fresh dated `.sqlite` database with normalized `cities`, `breweries`, `brewery_addresses`, `users`, and `user_addresses` tables. | | Log | `spdlog` writes results and warnings to the console. | If name sampling, enrichment, or generation fails for a city, that city is @@ -270,10 +270,10 @@ skipped and the pipeline continues. `GenerateUsers()` runs before public [Nominatim API](https://nominatim.openstreetmap.org), respecting its one-request-per-second usage policy. Falls back to a placeholder `address_line1` ("Address unavailable") and empty `postal_code` when the - lookup fails or returns no usable street address. `MockAddressService` is - the no-network substitute (`"123 Mock Street"`, `"00000"`) used in - `--mocked` runs. Only breweries get a resolved street address; generated - users carry a coordinate pair with no address fields. + lookup fails or returns no usable street address. `MockAddressService` is the + no-network substitute (`"123 Mock Street"`, `"00000"`) used in `--mocked` + runs. Only breweries get a resolved street address; generated users carry a + coordinate pair with no address fields. - `SqliteExportService`: creates a dated SQLite file per run and persists each successful user and brewery into normalized tables. - Brewery payloads include English and local-language name and description @@ -299,11 +299,11 @@ valid output on the first pass; the retry path is kept for resilience. `MockGenerator` uses stable hashes for repeatable output in demos and Storybook runs. -`NominatimAddressService.ReverseGeocode()` sleeps one second after every -request to respect Nominatim's usage policy, and returns `std::nullopt` (logged -as a warning, not a skipped city) if the request fails, the response has no -`address` object, or neither `house_number` nor `road` is present — the -brewery is still generated and exported, just with a placeholder address. +`NominatimAddressService.ReverseGeocode()` sleeps one second after every request +to respect Nominatim's usage policy, and returns `std::nullopt` (logged as a +warning, not a skipped city) if the request fails, the response has no `address` +object, or neither `house_number` nor `road` is present — the brewery is still +generated and exported, just with a placeholder address. `CuratedJsonDataService` memoizes each of `LoadLocations()`, `LoadPersonas()`, `LoadForenamesByCountry()`, and `LoadSurnamesByCountry()` independently the @@ -334,14 +334,14 @@ Each successful run stores a `BreweryRecord` pair with the source location and a `UserResult` payload. The same generated records are also written to a fresh SQLite export file named with the current UTC timestamp. -| Field | Meaning | -| ------------------- | ------------------------------------------ | -| `name_en` | Brewery name in English. | -| `description_en` | Brewery description in English. | -| `name_local` | Brewery name in the local language. | -| `description_local` | Brewery description in the local language. | +| Field | Meaning | +| ------------------- | ------------------------------------------------------------------------------------- | +| `name_en` | Brewery name in English. | +| `description_en` | Brewery description in English. | +| `name_local` | Brewery name in the local language. | +| `description_local` | Brewery description in the local language. | | `address_line1` | Street address resolved via reverse geocoding, or a placeholder if resolution failed. | -| `postal_code` | Postal code resolved via reverse geocoding, or empty if resolution failed. | +| `postal_code` | Postal code resolved via reverse geocoding, or empty if resolution failed. | | Field | Meaning | | ----------------- | -------------------------------------------------------------------------------------- | diff --git a/docs/pipeline/ROADMAP.md b/docs/pipeline/ROADMAP.md index a4ec9a5f..a63d78f4 100644 --- a/docs/pipeline/ROADMAP.md +++ b/docs/pipeline/ROADMAP.md @@ -155,8 +155,8 @@ Entirely new — no `includes/policy/` (or equivalent) directory exists today. `MockGenerator`/`LlamaGenerator`) that calls the OpenAI Chat Completions API with Structured Outputs (`json_schema`, `strict`) to guarantee schema-valid JSON without needing `IPromptFormatter` or a GBNF grammar — - selected via `--openai`/`GeneratorMode::kOpenAI`. This landed ahead of - the planned architecture in §3/§4 and isn't reflected there yet. + selected via `--openai`/`GeneratorMode::kOpenAI`. This landed ahead of the + planned architecture in §3/§4 and isn't reflected there yet. - [x] Implement `LlamaGenerator::GenerateUser` for real, with a retry loop mirroring `GenerateBrewery` (GBNF grammar, `ValidateUserJson`, up to 3 attempts with corrective feedback) and a new `prompts/USER_GENERATION.md`. @@ -195,8 +195,8 @@ Entirely new — no `includes/policy/` (or equivalent) directory exists today. `kCreateUserAddressesTableSql` in `includes/services/database/sqlite_statement_helpers.h` for the existing pattern to follow. -- [ ] Add a `brewery_cache_` alongside the existing `city_cache_`, and move - from one open transaction for the whole run to per-phase batched commits +- [ ] Add a `brewery_cache_` alongside the existing `city_cache_`, and move from + one open transaction for the whole run to per-phase batched commits (`BEGIN` / `COMMIT & BEGIN` on a batch-size threshold), as shown in the planned activity diagram. diff --git a/docs/pipeline/diagrams/current/activity.md b/docs/pipeline/diagrams/current/activity.md index 2a158cb2..49eb862b 100644 --- a/docs/pipeline/diagrams/current/activity.md +++ b/docs/pipeline/diagrams/current/activity.md @@ -1,6 +1,10 @@ # The Biergarten Data Pipeline (Current — Synchronous Data Path) -The only concurrency in the current pipeline is a dedicated log dispatcher thread that drains `log_channel` and forwards entries to spdlog for the entire run (joined during shutdown after `log_channel` closes). `BiergartenPipelineOrchestrator::Run()` itself is synchronous — sampling, enrichment, generation, and export all happen on the main thread. +The only concurrency in the current pipeline is a dedicated log dispatcher +thread that drains `log_channel` and forwards entries to spdlog for the entire +run (joined during shutdown after `log_channel` closes). +`BiergartenPipelineOrchestrator::Run()` itself is synchronous — sampling, +enrichment, generation, and export all happen on the main thread. ```mermaid --8<-- "pipeline/diagrams/current/activity.mmd" diff --git a/docs/pipeline/diagrams/planned/activity.md b/docs/pipeline/diagrams/planned/activity.md index 28c02068..76a5fac3 100644 --- a/docs/pipeline/diagrams/planned/activity.md +++ b/docs/pipeline/diagrams/planned/activity.md @@ -1,6 +1,11 @@ # The Biergarten Data Pipeline — Activity Diagram (Planned) -Each generation phase runs a producer/consumer trio concurrently: an **Orchestrator** producer feeds a bounded channel, an **LLM Worker** consumes it and generates records (closing its output channel when the input is drained and closed), and a **SQLite Worker** consumes that output channel and commits in batches. The Checkin and Follow phases run in parallel with each other once the user and brewery pools are populated. +Each generation phase runs a producer/consumer trio concurrently: an +**Orchestrator** producer feeds a bounded channel, an **LLM Worker** consumes it +and generates records (closing its output channel when the input is drained and +closed), and a **SQLite Worker** consumes that output channel and commits in +batches. The Checkin and Follow phases run in parallel with each other once the +user and brewery pools are populated. ```mermaid --8<-- "pipeline/diagrams/planned/activity.mmd" diff --git a/docs/pipeline/diagrams/planned/class.md b/docs/pipeline/diagrams/planned/class.md index 6217bcc1..64b63917 100644 --- a/docs/pipeline/diagrams/planned/class.md +++ b/docs/pipeline/diagrams/planned/class.md @@ -6,5 +6,12 @@ ## Notes -- `Logger`, `PipelineLogger`, and `LogWorker` are referenced only in relationships in the source PlantUML (not given their own class blocks there either) — they appear here as implicit nodes for the same reason. -- Package grouping from the PlantUML source (`Domain: Models`, `Domain: Application Configuration`, `Domain: Policy`, `Infrastructure: Logging`, `Infrastructure: Pipeline Channel`, `Infrastructure: Data Preloading`, `Infrastructure: Enrichment`, `Infrastructure: Prompting`, `Infrastructure: Data Generation`, `Infrastructure: Data Export`) is preserved as Mermaid `namespace` blocks. +- `Logger`, `PipelineLogger`, and `LogWorker` are referenced only in + relationships in the source PlantUML (not given their own class blocks there + either) — they appear here as implicit nodes for the same reason. +- Package grouping from the PlantUML source (`Domain: Models`, + `Domain: Application Configuration`, `Domain: Policy`, + `Infrastructure: Logging`, `Infrastructure: Pipeline Channel`, + `Infrastructure: Data Preloading`, `Infrastructure: Enrichment`, + `Infrastructure: Prompting`, `Infrastructure: Data Generation`, + `Infrastructure: Data Export`) is preserved as Mermaid `namespace` blocks. diff --git a/docs/web/architecture.md b/docs/web/architecture.md index ad6faabf..6c091e7d 100644 --- a/docs/web/architecture.md +++ b/docs/web/architecture.md @@ -45,12 +45,15 @@ Authentication workflow diagrams, one per flow, under ### Vertical-slice architecture pattern The backend organizes business capabilities as feature slices instead of -technical layers. Each feature (`Features.Users`, `Features.Breweries`, -`Features.Emails`, `Features.Locations`, `Features.PhotoUpload`) is a single -project that owns its own MediatR commands/queries/handlers, validators, and -repository end to end. `Features.Emails`, `Features.Locations`, and -`Features.PhotoUpload` have no `Controllers/` folder; all three are invoked -internally by other slices via MediatR/DI, never over HTTP: +technical layers. + +Each feature (`Features.Users`, `Features.Breweries`, `Features.Emails`, +`Features.Locations`, `Features.PhotoUpload`) is a single project that owns its +own MediatR commands/queries/handlers, validators, and repository end to end. + +`Features.Emails`, `Features.Locations`, and `Features.PhotoUpload` have no +`Controllers/` folder; all three are invoked internally by other slices via +MediatR/DI, never over HTTP: ```mermaid flowchart TB @@ -109,14 +112,6 @@ flowchart TB style S5 stroke-dasharray: 4 3 ``` -Slices generally don't reference each other's project. Most cross-slice -interactions go through a MediatR command instead — for example, -`Features.Users` triggering a confirmation email handled by `Features.Emails` -via a `SendRegistrationEmailCommand` whose contract lives in -`Shared.Application`, so neither slice takes a project reference on the other. -The one exception is `Features.Users`, which takes a direct `ProjectReference` -on `Features.PhotoUpload` to send `UploadPhotoCommand` for avatar uploads. - ### Layer responsibilities #### API layer (`API.Core`) @@ -163,12 +158,6 @@ own `Features.Emails` has no `Controllers/` folder. It's invoked only via MediatR commands sent from other slices, never over HTTP. -`Features.Locations` also has no `Controllers/` folder and no MediatR handlers; -it exposes `ILocationRepository` directly as a plain service, currently consumed -only by `Database.Seed` to look up cities while seeding brewery locations. Other -slices (for example, `Features.Breweries`) do not yet depend on it; they run -their own `CityID` existence checks inline. - `Features.PhotoUpload` also has no `Controllers/` folder; its `UploadPhotoCommand` is sent by other features' handlers (for example, a future brewery photo upload command) rather than bound to an HTTP route directly. @@ -182,8 +171,8 @@ brewery photo upload command) rather than bound to an HTTP route directly. `Infrastructure.Email.Templates` for Emails, `Infrastructure.FileUpload` for PhotoUpload) - `Shared.Contracts`, `Shared.Application` -- No other `Features.*` project, with one exception: `Features.Users` - references `Features.PhotoUpload` directly for avatar uploads +- No other `Features.*` project, with one exception: `Features.Users` references + `Features.PhotoUpload` directly for avatar uploads **Rules**: @@ -277,13 +266,15 @@ everything needed to understand or change one capability lives in one project - Each HTTP write operation is a `Command` (for example, `CreateBreweryCommand` in `Features.Breweries/Commands/CreateBrewery/`); each read operation is a `Query` (for example, `GetBreweryByIdQuery`) -- Controllers bind directly to the Command/Query as the request body; there is - no separate request DTO + mapping step for writes +- Controllers generally bind directly to the Command/Query as the request body; + there is no separate request DTO + mapping step for writes + - The exception to this is for authenticated routes, when the current user id + needs to be extracted from the authentication token to be used in a command. - A single shared `ValidationBehavior` (`Shared.Application/Behaviors/`) runs FluentValidation validators in the MediatR pipeline before any handler executes - Query handlers map to a dedicated response `Dto`, so domain entities never - leak over the wire + leak **Example**: @@ -351,16 +342,11 @@ method that registers its repository and slice-internal services **Strategy**: -- No ORM; each repository issues inline SQL through Dapper, which handles - parameter binding. The one wrinkle is the `GEOGRAPHY` column on - `BreweryPostLocation`, which Dapper can't deserialize as a UDT: reads select - `CONVERT(varbinary(max), Coordinates)` so the value comes back as a plain - `byte[]` Dapper can bind. Proximity search - (`GET /api/brewery/locations/nearby`) filters and orders rows server-side with - `Coordinates.STDistance(@Origin) <= @RangeInMetres`, avoiding a client-side - distance calculation over every row. No spatial index backs the query yet; - `schema.sql` sketches one, commented out, for when the table grows large - enough to need it +- Each repository issues inline SQL through Dapper, which handles parameter + binding. +- Proximity search (`GET /api/brewery/locations/nearby`) filters and orders rows + server-side with `Coordinates.STDistance(@Origin) <= @RangeInMetres`, avoiding + a client-side distance calculation over every row. - Referential checks (for example, "does this `CityId` exist?") are explicit `SELECT 1 ...` existence checks in the repository method, run inside the same transaction as the write @@ -372,8 +358,8 @@ method that registers its repository and slice-internal services - Application focuses on orchestration; the database enforces integrity via keys, `CHECK` constraints, and cascades -See [Database](database.md) for the schema and the app's SQL -error-handling approach in more detail. +See [Database](database.md) for the schema and the app's SQL error-handling +approach in more detail. ## Frontend architecture @@ -441,43 +427,25 @@ reference. Product and engineering documentation points to `web/frontend`. ### Authentication flow -1. **Registration**: - - User submits credentials - - Password hashed with Argon2id - - User account created (unverified) and a confirmation email is dispatched - via `Features.Emails` - -2. **Email confirmation**: - - User follows the confirmation link/token from the email - - `Features.Users`' `ConfirmUser` command marks the account verified - -3. **Login**: - - User submits credentials - - Password verified against hash - - Access and refresh JWTs are issued and returned to the client; neither is - stored server-side - -4. **Token refresh**: - - Client exchanges a valid, unexpired refresh token for a new access/refresh - pair via `Features.Users`' `RefreshToken` command - - Refresh tokens are stateless JWTs, not tracked server-side: the previous - refresh token is not invalidated and remains usable until its own - expiration - -5. **API Requests**: - - Client sends the access JWT in the `Authorization` header - - `JwtAuthenticationHandler` validates the token - - Request proceeds if valid +| Flow | Details | +| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Registration** | User submits credentials; password is hashed with Argon2id; user account is created as unverified and a confirmation email is dispatched via `Features.Emails`. | +| **Email confirmation** | User follows the confirmation link/token from the email; `Features.Users`' `ConfirmUser` command marks the account as verified. | +| **Login** | User submits credentials; password is verified against the hash; access and refresh JWTs are issued and returned to the client; neither is stored server-side. | +| **Token refresh** | Client exchanges a valid, unexpired refresh token for a new access/refresh pair via `Features.Users`' `RefreshToken` command; refresh tokens are stateless JWTs, not tracked server-side, and the previous refresh token remains usable until its own expiration. | +| **API requests** | Client sends the access JWT in the `Authorization` header; `JwtAuthenticationHandler` validates the token; request proceeds if valid. | ### Password security **Algorithm**: Argon2id -- Memory: 64MB -- Iterations: 4 -- Parallelism: 2 (hardcoded, to avoid thread-pool exhaustion) -- Salt: 128-bit (16 bytes) -- Hash: 256-bit (32 bytes) +| Setting | Value | +| ----------- | ---------------------------------------------- | +| Memory | 64MB | +| Iterations | 4 | +| Parallelism | 2 (hardcoded, to avoid thread-pool exhaustion) | +| Salt | 128-bit (16 bytes) | +| Hash | 256-bit (32 bytes) | ### JWT tokens @@ -485,11 +453,13 @@ reference. Product and engineering documentation points to `web/frontend`. **Claims**: -- `sub` - User ID -- `unique_name` - Username -- `jti` - Unique token ID -- `iat` - Issued at timestamp -- `exp` - Expiration timestamp +| Claim | Meaning | +| ------------- | -------------------- | +| `sub` | User ID | +| `unique_name` | Username | +| `jti` | Unique token ID | +| `iat` | Issued at timestamp | +| `exp` | Expiration timestamp | **Configuration** (appsettings.json): @@ -503,11 +473,10 @@ reference. Product and engineering documentation points to `web/frontend`. } ``` -This block exists in `appsettings.json`, but nothing in the code currently -reads it: actual token lifetimes come from the hardcoded -`TokenServiceExpirationHours` constants, and `JwtInfrastructure` hardcodes -`ValidateIssuer`/`ValidateAudience` to `false`, so `Issuer`/`Audience` are not -enforced. +This block exists in `appsettings.json`, but nothing in the code currently reads +it: actual token lifetimes come from the hardcoded `TokenServiceExpirationHours` +constants, and `JwtInfrastructure` hardcodes `ValidateIssuer`/`ValidateAudience` +to `false`, so `Issuer`/`Audience` are not enforced. ## Database architecture @@ -563,7 +532,8 @@ tracking stays valid. **Purpose**: Populate development/test databases with realistic data **Implementation**: `Database.Seed` project, using data produced by the C++ -pipeline under `tooling/pipeline/` (see [Pipeline README](../pipeline/README.md)) +pipeline under `tooling/pipeline/` (see +[Pipeline README](../pipeline/README.md)) **Seed Data**: @@ -573,11 +543,9 @@ pipeline under `tooling/pipeline/` (see [Pipeline README](../pipeline/README.md) - User accounts (via `Features.Users`' repository) - Brewery posts with locations (via `Features.Breweries`' repository) -`Photo` rows are written via `Features.PhotoUpload`'s `IPhotoUploadRepository`, -but `Database.Seed` doesn't call it, so photos aren't part of the seed data. -Tables that exist in the schema but have no repository yet (`UserAvatar`, -`UserFollow`, `BeerStyle`, `BeerPost`, `BeerPostPhoto`, `BeerPostComment`, -`BreweryPostPhoto`) are not seeded either. +Tables that exist in the schema but have no repository yet ( `UserFollow`, +`BeerStyle`, `BeerPost`, `BeerPostPhoto`, `BeerPostComment`, `BreweryPostPhoto`) +are not seeded either. ## Deployment architecture @@ -585,18 +553,22 @@ Tables that exist in the schema but have no repository yet (`UserAvatar`, **Container Structure**: -- `sqlserver` - SQL Server 2022 -- `database.migrations` - Schema migration runner -- `database.seed` - Data seeder -- `api.core` - ASP.NET Core Web API -- `mailpit` - Local dev/test SMTP server + web UI (not used in production) -- `seaweedfs` - S3-compatible object storage for uploaded photos (dev only) +| Container | Purpose | +| --------------------- | ------------------------------------------------------------ | +| `sqlserver` | SQL Server 2022 | +| `database.migrations` | Schema migration runner | +| `database.seed` | Data seeder | +| `api.core` | ASP.NET Core Web API | +| `mailpit` | Local dev/test SMTP server + web UI (not used in production) | +| `seaweedfs` | S3-compatible object storage for uploaded photos (dev only) | **Environments**: -- Development (`docker-compose.dev.yaml`) -- Testing (`docker-compose.test.yaml`) -- Production (`docker-compose.prod.yaml`) +| Environment | Compose file | +| ----------- | -------------------------- | +| Development | `docker-compose.dev.yaml` | +| Testing | `docker-compose.test.yaml` | +| Production | `docker-compose.prod.yaml` | For details, see [Docker Guide](docker.md). diff --git a/docs/web/database.md b/docs/web/database.md index 7217fcf4..34f5c0e8 100644 --- a/docs/web/database.md +++ b/docs/web/database.md @@ -20,24 +20,24 @@ The full schema lives under script per table. ``` -└── 01-schema - ├── 01-UserAccount.sql - ├── 02-Photo.sql - ├── 03-UserVerification.sql - ├── 04-UserCredential.sql - ├── 05-UserFollow.sql - ├── 06-Country.sql - ├── 07-StateProvince.sql - ├── 08-City.sql - ├── 09-BreweryPost.sql - ├── 10-BreweryPostLocation.sql - ├── 11-BreweryPostPhoto.sql - ├── 12-BeerStyle.sql - ├── 13-BeerPost.sql - ├── 14-BeerPostPhoto.sql - ├── 15-BeerPostComment.sql - ├── 16-UserProfile.sql - └── 17-UserAvatar.sql +01-schema + ├── 01-UserAccount.sql + ├── 02-Photo.sql + ├── 03-UserVerification.sql + ├── 04-UserCredential.sql + ├── 05-UserFollow.sql + ├── 06-Country.sql + ├── 07-StateProvince.sql + ├── 08-City.sql + ├── 09-BreweryPost.sql + ├── 10-BreweryPostLocation.sql + ├── 11-BreweryPostPhoto.sql + ├── 12-BeerStyle.sql + ├── 13-BeerPost.sql + ├── 14-BeerPostPhoto.sql + ├── 15-BeerPostComment.sql + ├── 16-UserProfile.sql + └── 17-UserAvatar.sql ``` Each script is run in order by `Database.Migrations` (DbUp) on startup. Each diff --git a/docs/web/diagrams/auth/error-handling.md b/docs/web/diagrams/auth/error-handling.md index bf8b9909..0057ba00 100644 --- a/docs/web/diagrams/auth/error-handling.md +++ b/docs/web/diagrams/auth/error-handling.md @@ -1,6 +1,10 @@ # User Authentication Flow — Error Handling -Global exception mapping performed by `API.Core`'s `GlobalExceptionFilter`. Applies uniformly across every authentication endpoint (registration, login, refresh, confirm, resend, update-username/email/password/profile, delete-account) — see the per-flow diagrams for where each exception type is thrown. +Global exception mapping performed by `API.Core`'s `GlobalExceptionFilter`. +Applies uniformly across every authentication endpoint (registration, login, +refresh, confirm, resend, update-username/email/password/profile, +delete-account) — see the per-flow diagrams for where each exception type is +thrown. ```mermaid --8<-- "web/diagrams/auth/error-handling.mmd" diff --git a/docs/web/diagrams/database-schema.md b/docs/web/diagrams/database-schema.md index b9df13d6..9d3f5dee 100644 --- a/docs/web/diagrams/database-schema.md +++ b/docs/web/diagrams/database-schema.md @@ -1,6 +1,8 @@ # Database Schema -Every table also has a `RowVersion ROWVERSION` column (optimistic concurrency), omitted below for brevity. Tables marked `(*)` have no repository/feature slice yet. +Every table also has a `RowVersion ROWVERSION` column (optimistic concurrency), +omitted below for brevity. Tables marked `(*)` have no repository/feature slice +yet. ```mermaid --8<-- "web/diagrams/database-schema.mmd" diff --git a/docs/web/diagrams/deployment.md b/docs/web/diagrams/deployment.md index 0d425d4b..1f66ffbf 100644 --- a/docs/web/diagrams/deployment.md +++ b/docs/web/diagrams/deployment.md @@ -6,10 +6,12 @@ ## Notes -- **Dev network** (`devnet`, bridge): internal DNS resolves service names such as `sqlserver` and `api.core`. Volumes: `sqlserverdata-dev`, `nuget-cache-dev`, `seaweedfsdata-dev`. Startup order: SQL Server (health check) → migrations (run-once) → seed (run-once, depends on `seaweedfs`) → API (long-running, depends on seed). -- **Test network** (`testnet`, bridge): all test components isolated; fresh DB instance each run (`CLEAR_DATABASE=true`, ephemeral `sqlserverdata-test`/`seaweedfsdata-test` volumes). `api.specs`, `unit.tests`, and `frontend.tests` all run as separate containers, results aggregated. -- **Prod network** (`prodnet`, bridge): isolated from dev/test networks. Volumes: `sqlserverdata-prod`, `nuget-cache-prod`. No seed step in production. -- **Database-only environment**: same devnet volumes/images as `docker-compose.dev.yaml`, minus `api.core`, `frontend`, and `mailpit` — for working against the database (and object storage, since `seaweedfs` is still included) without running the API or website. -- **Minimal environment**: database + migrations + seed + `seaweedfs` + mailpit, no API or frontend container — for frontend development against an `API.Core` process run outside Docker. -- `unit.tests` builds `Dockerfile.tests`, which restores/builds against `Biergarten.API.slnx` then runs `dotnet test` for every `Features/*.Tests` project in turn (`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`, `Features.PhotoUpload.Tests`), mocking each handler's repository/service dependencies with Moq — no real database needed. -- `.trx` results are readable by Visual Studio, Azure DevOps, and GitHub Actions. +| Environment | Details | +| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Dev network** (`devnet`, bridge) | Internal DNS resolves service names such as `sqlserver` and `api.core`. Volumes: `sqlserverdata-dev`, `nuget-cache-dev`, `seaweedfsdata-dev`. Startup order: SQL Server (health check) → migrations (run-once) → seed (run-once, depends on `seaweedfs`) → API (long-running, depends on seed). | +| **Test network** (`testnet`, bridge) | All test components are isolated; fresh DB instance each run (`CLEAR_DATABASE=true`, ephemeral `sqlserverdata-test` / `seaweedfsdata-test` volumes). `api.specs`, `unit.tests`, and `frontend.tests` all run as separate containers; results are aggregated. | +| **Prod network** (`prodnet`, bridge) | Isolated from dev/test networks. Volumes: `sqlserverdata-prod`, `nuget-cache-prod`. No seed step in production. | +| **Database-only environment** | Same devnet volumes/images as `docker-compose.dev.yaml`, minus `api.core`, `frontend`, and `mailpit` — for working against the database (and object storage, since `seaweedfs` is still included) without running the API or website. | +| **Minimal environment** | Database + migrations + seed + `seaweedfs` + mailpit, no API or frontend container — for frontend development against an `API.Core` process run outside Docker. | +| `unit.tests` | Builds `Dockerfile.tests`, which restores/builds against `Biergarten.API.slnx` and then runs `dotnet test` for every `Features/*.Tests` project in turn (`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`, `Features.PhotoUpload.Tests`), mocking each handler's repository/service dependencies with Moq — no real database needed. | +| `.trx` results | Readable by Visual Studio, Azure DevOps, and GitHub Actions. | diff --git a/docs/web/diagrams/vertical-slice.md b/docs/web/diagrams/vertical-slice.md index 0391d46e..69d4dacb 100644 --- a/docs/web/diagrams/vertical-slice.md +++ b/docs/web/diagrams/vertical-slice.md @@ -17,12 +17,12 @@ doesn't wire up any slice; it applies schema directly. ## Project reference dependencies Solid arrows are compile-time project references. Dashed arrows are the two -places slices talk to each other without a direct reference: the email -MediatR contract routed through `Shared.Application`, and the one place that -pattern is broken outright. Every DB-backed slice depends on the same three -foundation projects (`Shared.Contracts`, `Domain.Entities`, -`Database.Connection`) — drawn as one box instead of fifteen separate arrows; -see the notes for what else each of those foundation projects pulls in. +places slices talk to each other without a direct reference: the email MediatR +contract routed through `Shared.Application`, and the one place that pattern is +broken outright. Every DB-backed slice depends on the same three foundation +projects (`Shared.Contracts`, `Domain.Entities`, `Database.Connection`) — drawn +as one box instead of fifteen separate arrows; see the notes for what else each +of those foundation projects pulls in. ```mermaid --8<-- "web/diagrams/vertical-slice/project-references.mmd" @@ -33,32 +33,49 @@ see the notes for what else each of those foundation projects pulls in. - **API.Core**: Program.cs wiring only - MediatR + `AddApplicationPart` per HTTP-exposed `Features.*` assembly - Swagger/OpenAPI, health checks, JWT auth middleware, global exception filter - - Only `Features.Users` and `Features.Breweries` get `AddApplicationPart` (controllers) - - `Features.PhotoUpload` and `Features.Emails` scanned for MediatR handlers only - - `Features.Locations` and `Features.Beers` not referenced by `API.Core` at all + - Only `Features.Users` and `Features.Breweries` get `AddApplicationPart` + (controllers) + - `Features.PhotoUpload` and `Features.Emails` scanned for MediatR handlers + only + - `Features.Locations` and `Features.Beers` not referenced by `API.Core` at + all -- **Feature slices**: each owns its own controller (where present), commands/queries + handlers, validators, and Dapper repository - - `Features.Users` covers auth (`Commands/Authentication`), account management (`Commands/Account`), and profile/avatar (`Commands/Profile`) - - internally namespaced `Features.Auth`, despite project name `Features.Users` - - `Features.Emails` and `Features.PhotoUpload` have no controller — invoked only via MediatR commands sent from other slices - - `Features.Locations` has no controller; one MediatR handler (`GetOrCreateCityHandler`) registered by `Database.Seed` only — `API.Core` never scans it - - `Features.Beers` is scaffolded (project + references only, no source files yet) +- **Feature slices**: each owns its own controller (where present), + commands/queries + handlers, validators, and Dapper repository + - `Features.Users` covers auth (`Commands/Authentication`), account management + (`Commands/Account`), and profile/avatar (`Commands/Profile`) + - internally namespaced `Features.Auth`, despite project name + `Features.Users` + - `Features.Emails` and `Features.PhotoUpload` have no controller — invoked + only via MediatR commands sent from other slices + - `Features.Locations` has no controller; one MediatR handler + (`GetOrCreateCityHandler`) registered by `Database.Seed` only — `API.Core` + never scans it + - `Features.Beers` is scaffolded (project + references only, no source files + yet) -- **The one exception to slice isolation**: `Features.Users` takes a direct project reference to `Features.PhotoUpload` +- **The one exception to slice isolation**: `Features.Users` takes a direct + project reference to `Features.PhotoUpload` - sends its `UploadPhotoCommand` via MediatR from `UploadAvatarHandler` - - unlike the email flow, the command type lives in the target slice itself, not in `Shared.Application` + - unlike the email flow, the command type lives in the target slice itself, + not in `Shared.Application` - **Shared.Application**: - `ValidationBehavior` (MediatR pipeline) - - Cross-slice email commands (`SendRegistrationEmailCommand`, `SendResendConfirmationEmailCommand`) + - Cross-slice email commands (`SendRegistrationEmailCommand`, + `SendResendConfirmationEmailCommand`) - MediatR command contract (not project reference) - - Every slice (including `Features.Emails`) references it for the pipeline behavior alone + - Every slice (including `Features.Emails`) references it for the pipeline + behavior alone - **Domain.Entities**: - - `UserAccount`, `UserCredential`, `UserVerification`, `UserProfile`, `UserAvatar` + - `UserAccount`, `UserCredential`, `UserVerification`, `UserProfile`, + `UserAvatar` - `BreweryPost`, `BreweryPostLocation` - `City`, `StateProvince`, `Country` - - `BeerPost`, `BeerStyle`, `Photo` (schema tables exist for `BeerPost`/`BeerStyle`; no repository yet since `Features.Beers` has no code) + - `BeerPost`, `BeerStyle`, `Photo` (schema tables exist for + `BeerPost`/`BeerStyle`; no repository yet since `Features.Beers` has no + code) - **Database.Connection**: the shared connection-factory project - each slice's own repository issues inline SQL via Dapper against it @@ -68,7 +85,8 @@ see the notes for what else each of those foundation projects pulls in. - **Database.Migrations**: separate console app - `schema.dbml` + SQL scripts - applies DDL directly - - distinct from `Database.Seed`, which loads data through the feature slices' own DI extensions + - distinct from `Database.Seed`, which loads data through the feature slices' + own DI extensions - **Database.Seed**: - calls each slice's `AddFeatures*()` DI extension directly @@ -76,6 +94,10 @@ see the notes for what else each of those foundation projects pulls in. - no API host, no shared pipeline with `API.Core` - **Omitted from the diagram**: - - `Domain.Exceptions` (referenced by nearly every project directly, including all of `Shared.Contracts`, `Infrastructure.Jwt`, and each feature slice) - - `Infrastructure.Configuration` (the shared options-binding project referenced by `Database.Connection`, `Infrastructure.Email`, `Infrastructure.FileUpload`, and `Features.Emails` directly) - - both are plumbing-level dependencies of nearly everything above, and drawing them would just add a second copy of the same fan-in + - `Domain.Exceptions` (referenced by nearly every project directly, including + all of `Shared.Contracts`, `Infrastructure.Jwt`, and each feature slice) + - `Infrastructure.Configuration` (the shared options-binding project + referenced by `Database.Connection`, `Infrastructure.Email`, + `Infrastructure.FileUpload`, and `Features.Emails` directly) + - both are plumbing-level dependencies of nearly everything above, and drawing + them would just add a second copy of the same fan-in diff --git a/docs/web/docker.md b/docs/web/docker.md index 8f4f8f9d..a097d10f 100644 --- a/docs/web/docker.md +++ b/docs/web/docker.md @@ -21,8 +21,7 @@ The project uses Docker Compose to orchestrate multiple services: - React Router website (SSR frontend) - Test runners -See the [deployment diagram](diagrams/deployment.md) for visual -representation. +See the [deployment diagram](diagrams/deployment.md) for visual representation. ## Docker Compose environments diff --git a/docs/web/environment-variables.md b/docs/web/environment-variables.md index 232f0fde..beae6701 100644 --- a/docs/web/environment-variables.md +++ b/docs/web/environment-variables.md @@ -308,9 +308,9 @@ cd web See [Generating an env file](#generating-an-env-file) above — `generate-env.sh` randomizes the secret-bearing values, so it's the preferred way to create any -`.env.dev`/`.env.test`/`.env.prod` file. A plain `cp web/.env.example -web/.env.dev` works too, but leaves the placeholder secrets from the template -in place; edit them manually if you go that route. +`.env.dev`/`.env.test`/`.env.prod` file. A plain +`cp web/.env.example web/.env.dev` works too, but leaves the placeholder secrets +from the template in place; edit them manually if you go that route. **Docker Compose Mapping**: diff --git a/docs/web/getting-started.md b/docs/web/getting-started.md index 26aee44c..dcfa6517 100644 --- a/docs/web/getting-started.md +++ b/docs/web/getting-started.md @@ -70,10 +70,12 @@ and SeaweedFS (local dev S3-compatible object storage for photo uploads). Available endpoints: -- API Swagger: http://localhost:8080/swagger -- Health Check: http://localhost:8080/health -- Mailpit UI: http://localhost:8025 -- SeaweedFS S3 API: http://localhost:8333 +| Service | URL | +| ---------------- | ----------------------------- | +| API Swagger | http://localhost:8080/swagger | +| Health Check | http://localhost:8080/health | +| Mailpit UI | http://localhost:8025 | +| SeaweedFS S3 API | http://localhost:8333 | ### 4. Start the frontend diff --git a/docs/web/testing.md b/docs/web/testing.md index 7d716ddd..0eb1c74d 100644 --- a/docs/web/testing.md +++ b/docs/web/testing.md @@ -16,16 +16,12 @@ Biergarten App. The project uses a multi-layered testing approach across backend and frontend: -- **API.Specs** - BDD integration tests using Reqnroll (Gherkin), run against a - live, seeded database -- **Features.\*.Tests** - One unit test project per backend feature slice - (`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`, - `Features.PhotoUpload.Tests`), covering that slice's command/query handlers, - with dependencies mocked via Moq (no real database required) -- **Storybook Vitest project** - Browser-based interaction tests for shared - website stories -- **Storybook Playwright suite** - Browser checks against Storybook-rendered - components +| Suite | Purpose | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **API.Specs** | BDD integration tests using Reqnroll (Gherkin), run against a live, seeded database | +| **Features.\*.Tests** | One unit test project per backend feature slice (`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`, `Features.PhotoUpload.Tests`), covering that slice's command/query handlers, with dependencies mocked via Moq (no real database required) | +| **Storybook Vitest project** | Browser-based interaction tests for shared website stories | +| **Storybook Playwright suite** | Browser checks against Storybook-rendered components | ## Running tests with Docker (recommended) @@ -80,19 +76,6 @@ docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v You can run individual test projects locally without Docker: -### Integration tests (API.Specs) - -```bash -cd web/backend -dotnet test API/API.Specs/API.Specs.csproj -``` - -**Requirements**: - -- SQL Server instance running -- Database migrated and seeded -- Environment variables set (DB connection, JWT secret) - ### Feature slice unit tests Each feature slice has its own test project, covering its command/query @@ -122,6 +105,19 @@ npm install npm run test:storybook ``` +### Integration tests (API.Specs) + +```bash +cd web/backend +dotnet test API/API.Specs/API.Specs.csproj +``` + +**Requirements**: + +- SQL Server instance running +- Database migrated and seeded +- Environment variables set (DB connection, JWT secret) + **Purpose**: - Verifies shared stories such as form fields, submit buttons, navbar states, diff --git a/docs/web/token-validation.md b/docs/web/token-validation.md index 0153145a..bfb0ad65 100644 --- a/docs/web/token-validation.md +++ b/docs/web/token-validation.md @@ -12,11 +12,11 @@ tags: The Biergarten API validates JSON Web Tokens (JWTs) across three token types: -| Token Type | Lifetime | Purpose | -| --- | --- | --- | -| **Access Tokens** | 1 hour | API authentication | -| **Refresh Tokens** | 21 days | Obtaining new access tokens | -| **Confirmation Tokens** | 30 minutes | Email confirmation | +| Token Type | Lifetime | Purpose | +| ----------------------- | ---------- | --------------------------- | +| **Access Tokens** | 1 hour | API authentication | +| **Refresh Tokens** | 21 days | Obtaining new access tokens | +| **Confirmation Tokens** | 30 minutes | Email confirmation | ## Components @@ -48,16 +48,15 @@ Token generation and validation live on the same slice-internal service. - `GenerateAccessToken(Guid userId, string username)` - Creates 1-hour access token -- `GenerateRefreshToken(Guid userId, string username)` - Creates 21-day - refresh token -- `GenerateConfirmationToken(Guid userId, string username)` - Creates - 30-minute confirmation token +- `GenerateRefreshToken(Guid userId, string username)` - Creates 21-day refresh + token +- `GenerateConfirmationToken(Guid userId, string username)` - Creates 30-minute + confirmation token **Validation methods:** - `ValidateRefreshTokenAsync(string token)` - Validates refresh tokens -- `ValidateConfirmationTokenAsync(string token)` - Validates confirmation - tokens +- `ValidateConfirmationTokenAsync(string token)` - Validates confirmation tokens There is no `ValidateAccessTokenAsync` on `ITokenService`. Access-token validation doesn't go through `ITokenService` at all: `JwtAuthenticationHandler` @@ -181,43 +180,43 @@ Each token is validated for: Validation failures return HTTP 401 Unauthorized. Regardless of the specific cause (invalid signature, expired token, missing header, malformed claims), -`JwtAuthenticationHandler.HandleChallengeAsync` always returns the same -generic message: `"Unauthorized: Invalid or missing authentication token"` — -it doesn't distinguish failure reasons in the response. +`JwtAuthenticationHandler.HandleChallengeAsync` always returns the same generic +message: `"Unauthorized: Invalid or missing authentication token"` — it doesn't +distinguish failure reasons in the response. ## Token lifecycle ### Access token lifecycle -| Step | Description | -| --- | --- | -| Generation | During login (1-hour validity) | -| Usage | Included in Authorization header on API requests | -| Validation | Validated on protected endpoints | -| Expiration | Token becomes invalid after 1 hour | -| Refresh | Use refresh token to obtain new access token | +| Step | Description | +| ---------- | ------------------------------------------------ | +| Generation | During login (1-hour validity) | +| Usage | Included in Authorization header on API requests | +| Validation | Validated on protected endpoints | +| Expiration | Token becomes invalid after 1 hour | +| Refresh | Use refresh token to obtain new access token | ### Refresh token lifecycle -| Step | Description | -| --- | --- | -| Generation | During login (21-day validity) | -| Storage | Client-side (secure storage) | -| Usage | Posted to `/api/auth/refresh` endpoint | +| Step | Description | +| ---------- | ------------------------------------------------ | +| Generation | During login (21-day validity) | +| Storage | Client-side (secure storage) | +| Usage | Posted to `/api/auth/refresh` endpoint | | Validation | Validated by `ITokenService.RefreshTokenAsync()` | -| Rotation | New refresh token issued on successful refresh | -| Expiration | Token becomes invalid after 21 days | +| Rotation | New refresh token issued on successful refresh | +| Expiration | Token becomes invalid after 21 days | ### Confirmation token lifecycle -| Step | Description | -| --- | --- | -| Generation | During user registration (30-minute validity) | -| Delivery | Emailed to user in confirmation link | -| Usage | User clicks link, token posted to `/api/auth/confirm` | -| Validation | Validated by `ConfirmUserHandler` | -| Completion | User account marked as confirmed | -| Expiration | Token becomes invalid after 30 minutes | +| Step | Description | +| ---------- | ----------------------------------------------------- | +| Generation | During user registration (30-minute validity) | +| Delivery | Emailed to user in confirmation link | +| Usage | User clicks link, token posted to `/api/auth/confirm` | +| Validation | Validated by `ConfirmUserHandler` | +| Completion | User account marked as confirmed | +| Expiration | Token becomes invalid after 30 minutes | ## Testing @@ -277,22 +276,18 @@ All of the following live in `Features.Users.Tests`. ### Stretch goals -1. **Middleware for Access Token Validation** - - Automatically validate access tokens on protected routes - - Populate HttpContext.User from token claims - - Return 401 for invalid/missing tokens +1. **Middleware for Access Token Validation** - Automatically validate access + tokens on protected routes - Populate HttpContext.User from token claims - + Return 401 for invalid/missing tokens -2. **Token Denylisting** - - Implement token revocation (for example, on logout) - - Store denylisted tokens in cache/database - - Check the denylist during validation +2. **Token Denylisting** - Implement token revocation (for example, on logout) - + Store denylisted tokens in cache/database - Check the denylist during + validation -3. **Refresh Token Rotation Strategy** - - Detect token reuse (replay attacks) - - Automatically invalidate entire token chain on reuse - - Log suspicious activity +3. **Refresh Token Rotation Strategy** - Detect token reuse (replay attacks) - + Automatically invalidate entire token chain on reuse - Log suspicious + activity -4. **Structured Logging** - - Log token validation attempts - - Track failed validation reasons - - Alert on repeated validation failures (brute force detection) +4. **Structured Logging** - Log token validation attempts - Track failed + validation reasons - Alert on repeated validation failures (brute force + detection)