Compare commits

..

2 Commits

Author SHA1 Message Date
Aaron Po
dee38fd1a3 Update docs formatting 2026-09-01 00:07:05 -04:00
Aaron Po
52910a17d5 Fix deploy-docs.yml 2026-08-31 22:51:35 -04:00
19 changed files with 293 additions and 283 deletions

View File

@@ -12,4 +12,4 @@ Welcome to the documentation for The Biergarten App.
- **[Architecture](web/architecture.md)** — System design and components - **[Architecture](web/architecture.md)** — System design and components
- **[Website](web/getting-started.md)** — Backend and frontend documentation - **[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

View File

@@ -306,13 +306,13 @@ consistent with the rest of the dataset: fixture data for a proof-of-concept,
not a source of truth. not a source of truth.
For brewery addresses specifically (not user addresses, which carry no street 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 address registry — `NominatimAddressService` reverse-geocodes the synthetic
coordinate pair against the live OpenStreetMap/Nominatim database, so the coordinate pair against the live OpenStreetMap/Nominatim database, so the street
street address is a genuine nearby address, just not the address of an actual address is a genuine nearby address, just not the address of an actual brewery.
brewery. This lookup only happens for non-`--mocked` runs; `--mocked` runs use This lookup only happens for non-`--mocked` runs; `--mocked` runs use
`MockAddressService`'s fixed placeholder address instead, which is not looked `MockAddressService`'s fixed placeholder address instead, which is not looked up
up from any real registry. from any real registry.
--- ---

View File

@@ -110,27 +110,27 @@ Each run writes a fresh dated SQLite file such as
#### CLI flags #### CLI flags
| Flag | Purpose | | Flag | Purpose |
| ------------------ | ------------------------------------------------------------------------------------------------------------ | | ------------------ | ---------------------------------------------------------------------------------------------------------------- |
| `--mocked` | Deterministic mock generator, no model required. | | `--mocked` | Deterministic mock generator, no model required. |
| `--model, -m` | Path to a GGUF file. Selects the local Llama.cpp generator. | | `--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` | 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`. | | `--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. | | `--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`. | | `--output, -o` | Directory for generated SQLite artifacts. Default: `output`. |
| `--log-path` | Path for application logs. Default: `pipeline.log`. | | `--log-path` | Path for application logs. Default: `pipeline.log`. |
| `--location-count` | Number of cities to sample from `locations.json` per run. Default: `10`. | | `--location-count` | Number of cities to sample from `locations.json` per run. Default: `10`. |
| `--temperature` | Sampling temperature. Default: `1.0`. | | `--temperature` | Sampling temperature. Default: `1.0`. |
| `--top-p` | Nucleus sampling. Default: `0.95`. | | `--top-p` | Nucleus sampling. Default: `0.95`. |
| `--top-k` | Top-k sampling. Default: `64`. | | `--top-k` | Top-k sampling. Default: `64`. |
| `--n-ctx` | Context window size. Default: `8192`. | | `--n-ctx` | Context window size. Default: `8192`. |
| `--seed` | Random seed. Default: `-1` (random at runtime). | | `--seed` | Random seed. Default: `-1` (random at runtime). |
| `--n-gpu-layers` | Number of model layers to offload to GPU. Default: `0`. | | `--n-gpu-layers` | Number of model layers to offload to GPU. Default: `0`. |
| `--help, -h` | Print usage and exit. | | `--help, -h` | Print usage and exit. |
`--mocked`, `--model`, and `--openai` are mutually exclusive. Omitting all `--mocked`, `--model`, and `--openai` are mutually exclusive. Omitting all three
three exits with an error before the pipeline starts. Sampling flags are exits with an error before the pipeline starts. Sampling flags are ignored
ignored unless `--model` is set (Llama). unless `--model` is set (Llama).
The post-build step copies `prompts/` into `build/prompts/`. Rebuild after The post-build step copies `prompts/` into `build/prompts/`. Rebuild after
editing any prompt file. editing any prompt file.
@@ -238,8 +238,8 @@ to match your run.
| Sample | `BiergartenPipelineOrchestrator::QueryCitiesWithCountries()` samples `--location-count` locations per run (default `10`). | | 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. | | 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 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. | | 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. | | 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. | | Log | `spdlog` writes results and warnings to the console. |
If name sampling, enrichment, or generation fails for a city, that city is 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 public [Nominatim API](https://nominatim.openstreetmap.org), respecting its
one-request-per-second usage policy. Falls back to a placeholder one-request-per-second usage policy. Falls back to a placeholder
`address_line1` ("Address unavailable") and empty `postal_code` when the `address_line1` ("Address unavailable") and empty `postal_code` when the
lookup fails or returns no usable street address. `MockAddressService` is lookup fails or returns no usable street address. `MockAddressService` is the
the no-network substitute (`"123 Mock Street"`, `"00000"`) used in no-network substitute (`"123 Mock Street"`, `"00000"`) used in `--mocked`
`--mocked` runs. Only breweries get a resolved street address; generated runs. Only breweries get a resolved street address; generated users carry a
users carry a coordinate pair with no address fields. coordinate pair with no address fields.
- `SqliteExportService`: creates a dated SQLite file per run and persists each - `SqliteExportService`: creates a dated SQLite file per run and persists each
successful user and brewery into normalized tables. successful user and brewery into normalized tables.
- Brewery payloads include English and local-language name and description - 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 `MockGenerator` uses stable hashes for repeatable output in demos and Storybook
runs. runs.
`NominatimAddressService.ReverseGeocode()` sleeps one second after every `NominatimAddressService.ReverseGeocode()` sleeps one second after every request
request to respect Nominatim's usage policy, and returns `std::nullopt` (logged to respect Nominatim's usage policy, and returns `std::nullopt` (logged as a
as a warning, not a skipped city) if the request fails, the response has no warning, not a skipped city) if the request fails, the response has no `address`
`address` object, or neither `house_number` nor `road` is present — the object, or neither `house_number` nor `road` is present — the brewery is still
brewery is still generated and exported, just with a placeholder address. generated and exported, just with a placeholder address.
`CuratedJsonDataService` memoizes each of `LoadLocations()`, `LoadPersonas()`, `CuratedJsonDataService` memoizes each of `LoadLocations()`, `LoadPersonas()`,
`LoadForenamesByCountry()`, and `LoadSurnamesByCountry()` independently the `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 `UserResult` payload. The same generated records are also written to a fresh
SQLite export file named with the current UTC timestamp. SQLite export file named with the current UTC timestamp.
| Field | Meaning | | Field | Meaning |
| ------------------- | ------------------------------------------ | | ------------------- | ------------------------------------------------------------------------------------- |
| `name_en` | Brewery name in English. | | `name_en` | Brewery name in English. |
| `description_en` | Brewery description in English. | | `description_en` | Brewery description in English. |
| `name_local` | Brewery name in the local language. | | `name_local` | Brewery name in the local language. |
| `description_local` | Brewery description 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. | | `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 | | Field | Meaning |
| ----------------- | -------------------------------------------------------------------------------------- | | ----------------- | -------------------------------------------------------------------------------------- |

View File

@@ -155,8 +155,8 @@ Entirely new — no `includes/policy/` (or equivalent) directory exists today.
`MockGenerator`/`LlamaGenerator`) that calls the OpenAI Chat Completions `MockGenerator`/`LlamaGenerator`) that calls the OpenAI Chat Completions
API with Structured Outputs (`json_schema`, `strict`) to guarantee API with Structured Outputs (`json_schema`, `strict`) to guarantee
schema-valid JSON without needing `IPromptFormatter` or a GBNF grammar — schema-valid JSON without needing `IPromptFormatter` or a GBNF grammar —
selected via `--openai`/`GeneratorMode::kOpenAI`. This landed ahead of selected via `--openai`/`GeneratorMode::kOpenAI`. This landed ahead of the
the planned architecture in §3/§4 and isn't reflected there yet. planned architecture in §3/§4 and isn't reflected there yet.
- [x] Implement `LlamaGenerator::GenerateUser` for real, with a retry loop - [x] Implement `LlamaGenerator::GenerateUser` for real, with a retry loop
mirroring `GenerateBrewery` (GBNF grammar, `ValidateUserJson`, up to 3 mirroring `GenerateBrewery` (GBNF grammar, `ValidateUserJson`, up to 3
attempts with corrective feedback) and a new `prompts/USER_GENERATION.md`. 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 `kCreateUserAddressesTableSql` in
`includes/services/database/sqlite_statement_helpers.h` for the existing `includes/services/database/sqlite_statement_helpers.h` for the existing
pattern to follow. pattern to follow.
- [ ] Add a `brewery_cache_` alongside the existing `city_cache_`, and move - [ ] Add a `brewery_cache_` alongside the existing `city_cache_`, and move from
from one open transaction for the whole run to per-phase batched commits one open transaction for the whole run to per-phase batched commits
(`BEGIN` / `COMMIT & BEGIN` on a batch-size threshold), as shown in the (`BEGIN` / `COMMIT & BEGIN` on a batch-size threshold), as shown in the
planned activity diagram. planned activity diagram.

View File

@@ -1,6 +1,10 @@
# The Biergarten Data Pipeline (Current — Synchronous Data Path) # 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 ```mermaid
--8<-- "pipeline/diagrams/current/activity.mmd" --8<-- "pipeline/diagrams/current/activity.mmd"

View File

@@ -1,6 +1,11 @@
# The Biergarten Data Pipeline — Activity Diagram (Planned) # 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 ```mermaid
--8<-- "pipeline/diagrams/planned/activity.mmd" --8<-- "pipeline/diagrams/planned/activity.mmd"

View File

@@ -6,5 +6,12 @@
## Notes ## 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. - `Logger`, `PipelineLogger`, and `LogWorker` are referenced only in
- 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. 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.

View File

@@ -45,12 +45,15 @@ Authentication workflow diagrams, one per flow, under
### Vertical-slice architecture pattern ### Vertical-slice architecture pattern
The backend organizes business capabilities as feature slices instead of The backend organizes business capabilities as feature slices instead of
technical layers. Each feature (`Features.Users`, `Features.Breweries`, technical layers.
`Features.Emails`, `Features.Locations`, `Features.PhotoUpload`) is a single
project that owns its own MediatR commands/queries/handlers, validators, and Each feature (`Features.Users`, `Features.Breweries`, `Features.Emails`,
repository end to end. `Features.Emails`, `Features.Locations`, and `Features.Locations`, `Features.PhotoUpload`) is a single project that owns its
`Features.PhotoUpload` have no `Controllers/` folder; all three are invoked own MediatR commands/queries/handlers, validators, and repository end to end.
internally by other slices via MediatR/DI, never over HTTP:
`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 ```mermaid
flowchart TB flowchart TB
@@ -109,14 +112,6 @@ flowchart TB
style S5 stroke-dasharray: 4 3 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 ### Layer responsibilities
#### API layer (`API.Core`) #### API layer (`API.Core`)
@@ -163,12 +158,6 @@ own
`Features.Emails` has no `Controllers/` folder. It's invoked only via MediatR `Features.Emails` has no `Controllers/` folder. It's invoked only via MediatR
commands sent from other slices, never over HTTP. 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 `Features.PhotoUpload` also has no `Controllers/` folder; its
`UploadPhotoCommand` is sent by other features' handlers (for example, a future `UploadPhotoCommand` is sent by other features' handlers (for example, a future
brewery photo upload command) rather than bound to an HTTP route directly. 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 `Infrastructure.Email.Templates` for Emails, `Infrastructure.FileUpload` for
PhotoUpload) PhotoUpload)
- `Shared.Contracts`, `Shared.Application` - `Shared.Contracts`, `Shared.Application`
- No other `Features.*` project, with one exception: `Features.Users` - No other `Features.*` project, with one exception: `Features.Users` references
references `Features.PhotoUpload` directly for avatar uploads `Features.PhotoUpload` directly for avatar uploads
**Rules**: **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` - Each HTTP write operation is a `Command` (for example, `CreateBreweryCommand`
in `Features.Breweries/Commands/CreateBrewery/`); each read operation is a in `Features.Breweries/Commands/CreateBrewery/`); each read operation is a
`Query` (for example, `GetBreweryByIdQuery`) `Query` (for example, `GetBreweryByIdQuery`)
- Controllers bind directly to the Command/Query as the request body; there is - Controllers generally bind directly to the Command/Query as the request body;
no separate request DTO + mapping step for writes 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<TRequest,TResponse>` - A single shared `ValidationBehavior<TRequest,TResponse>`
(`Shared.Application/Behaviors/`) runs FluentValidation validators in the (`Shared.Application/Behaviors/`) runs FluentValidation validators in the
MediatR pipeline before any handler executes MediatR pipeline before any handler executes
- Query handlers map to a dedicated response `Dto`, so domain entities never - Query handlers map to a dedicated response `Dto`, so domain entities never
leak over the wire leak
**Example**: **Example**:
@@ -351,16 +342,11 @@ method that registers its repository and slice-internal services
**Strategy**: **Strategy**:
- No ORM; each repository issues inline SQL through Dapper, which handles - Each repository issues inline SQL through Dapper, which handles parameter
parameter binding. The one wrinkle is the `GEOGRAPHY` column on binding.
`BreweryPostLocation`, which Dapper can't deserialize as a UDT: reads select - Proximity search (`GET /api/brewery/locations/nearby`) filters and orders rows
`CONVERT(varbinary(max), Coordinates)` so the value comes back as a plain server-side with `Coordinates.STDistance(@Origin) <= @RangeInMetres`, avoiding
`byte[]` Dapper can bind. Proximity search a client-side distance calculation over every row.
(`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
- Referential checks (for example, "does this `CityId` exist?") are explicit - Referential checks (for example, "does this `CityId` exist?") are explicit
`SELECT 1 ...` existence checks in the repository method, run inside the same `SELECT 1 ...` existence checks in the repository method, run inside the same
transaction as the write 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 - Application focuses on orchestration; the database enforces integrity via
keys, `CHECK` constraints, and cascades keys, `CHECK` constraints, and cascades
See [Database](database.md) for the schema and the app's SQL See [Database](database.md) for the schema and the app's SQL error-handling
error-handling approach in more detail. approach in more detail.
## Frontend architecture ## Frontend architecture
@@ -441,43 +427,25 @@ reference. Product and engineering documentation points to `web/frontend`.
### Authentication flow ### Authentication flow
1. **Registration**: | Flow | Details |
- User submits credentials | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
- Password hashed with Argon2id | **Registration** | User submits credentials; password is hashed with Argon2id; user account is created as unverified and a confirmation email is dispatched via `Features.Emails`. |
- User account created (unverified) and a confirmation email is dispatched | **Email confirmation** | User follows the confirmation link/token from the email; `Features.Users`' `ConfirmUser` command marks the account as verified. |
via `Features.Emails` | **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. |
2. **Email confirmation**: | **API requests** | Client sends the access JWT in the `Authorization` header; `JwtAuthenticationHandler` validates the token; request proceeds if valid. |
- 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
### Password security ### Password security
**Algorithm**: Argon2id **Algorithm**: Argon2id
- Memory: 64MB | Setting | Value |
- Iterations: 4 | ----------- | ---------------------------------------------- |
- Parallelism: 2 (hardcoded, to avoid thread-pool exhaustion) | Memory | 64MB |
- Salt: 128-bit (16 bytes) | Iterations | 4 |
- Hash: 256-bit (32 bytes) | Parallelism | 2 (hardcoded, to avoid thread-pool exhaustion) |
| Salt | 128-bit (16 bytes) |
| Hash | 256-bit (32 bytes) |
### JWT tokens ### JWT tokens
@@ -485,11 +453,13 @@ reference. Product and engineering documentation points to `web/frontend`.
**Claims**: **Claims**:
- `sub` - User ID | Claim | Meaning |
- `unique_name` - Username | ------------- | -------------------- |
- `jti` - Unique token ID | `sub` | User ID |
- `iat` - Issued at timestamp | `unique_name` | Username |
- `exp` - Expiration timestamp | `jti` | Unique token ID |
| `iat` | Issued at timestamp |
| `exp` | Expiration timestamp |
**Configuration** (appsettings.json): **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 This block exists in `appsettings.json`, but nothing in the code currently reads
reads it: actual token lifetimes come from the hardcoded it: actual token lifetimes come from the hardcoded `TokenServiceExpirationHours`
`TokenServiceExpirationHours` constants, and `JwtInfrastructure` hardcodes constants, and `JwtInfrastructure` hardcodes `ValidateIssuer`/`ValidateAudience`
`ValidateIssuer`/`ValidateAudience` to `false`, so `Issuer`/`Audience` are not to `false`, so `Issuer`/`Audience` are not enforced.
enforced.
## Database architecture ## Database architecture
@@ -563,7 +532,8 @@ tracking stays valid.
**Purpose**: Populate development/test databases with realistic data **Purpose**: Populate development/test databases with realistic data
**Implementation**: `Database.Seed` project, using data produced by the C++ **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**: **Seed Data**:
@@ -573,11 +543,9 @@ pipeline under `tooling/pipeline/` (see [Pipeline README](../pipeline/README.md)
- User accounts (via `Features.Users`' repository) - User accounts (via `Features.Users`' repository)
- Brewery posts with locations (via `Features.Breweries`' repository) - Brewery posts with locations (via `Features.Breweries`' repository)
`Photo` rows are written via `Features.PhotoUpload`'s `IPhotoUploadRepository`, Tables that exist in the schema but have no repository yet ( `UserFollow`,
but `Database.Seed` doesn't call it, so photos aren't part of the seed data. `BeerStyle`, `BeerPost`, `BeerPostPhoto`, `BeerPostComment`, `BreweryPostPhoto`)
Tables that exist in the schema but have no repository yet (`UserAvatar`, are not seeded either.
`UserFollow`, `BeerStyle`, `BeerPost`, `BeerPostPhoto`, `BeerPostComment`,
`BreweryPostPhoto`) are not seeded either.
## Deployment architecture ## Deployment architecture
@@ -585,18 +553,22 @@ Tables that exist in the schema but have no repository yet (`UserAvatar`,
**Container Structure**: **Container Structure**:
- `sqlserver` - SQL Server 2022 | Container | Purpose |
- `database.migrations` - Schema migration runner | --------------------- | ------------------------------------------------------------ |
- `database.seed` - Data seeder | `sqlserver` | SQL Server 2022 |
- `api.core` - ASP.NET Core Web API | `database.migrations` | Schema migration runner |
- `mailpit` - Local dev/test SMTP server + web UI (not used in production) | `database.seed` | Data seeder |
- `seaweedfs` - S3-compatible object storage for uploaded photos (dev only) | `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**: **Environments**:
- Development (`docker-compose.dev.yaml`) | Environment | Compose file |
- Testing (`docker-compose.test.yaml`) | ----------- | -------------------------- |
- Production (`docker-compose.prod.yaml`) | Development | `docker-compose.dev.yaml` |
| Testing | `docker-compose.test.yaml` |
| Production | `docker-compose.prod.yaml` |
For details, see [Docker Guide](docker.md). For details, see [Docker Guide](docker.md).

View File

@@ -20,24 +20,24 @@ The full schema lives under
script per table. script per table.
``` ```
└── 01-schema 01-schema
├── 01-UserAccount.sql ├── 01-UserAccount.sql
├── 02-Photo.sql ├── 02-Photo.sql
├── 03-UserVerification.sql ├── 03-UserVerification.sql
├── 04-UserCredential.sql ├── 04-UserCredential.sql
├── 05-UserFollow.sql ├── 05-UserFollow.sql
├── 06-Country.sql ├── 06-Country.sql
├── 07-StateProvince.sql ├── 07-StateProvince.sql
├── 08-City.sql ├── 08-City.sql
├── 09-BreweryPost.sql ├── 09-BreweryPost.sql
├── 10-BreweryPostLocation.sql ├── 10-BreweryPostLocation.sql
├── 11-BreweryPostPhoto.sql ├── 11-BreweryPostPhoto.sql
├── 12-BeerStyle.sql ├── 12-BeerStyle.sql
├── 13-BeerPost.sql ├── 13-BeerPost.sql
├── 14-BeerPostPhoto.sql ├── 14-BeerPostPhoto.sql
├── 15-BeerPostComment.sql ├── 15-BeerPostComment.sql
├── 16-UserProfile.sql ├── 16-UserProfile.sql
└── 17-UserAvatar.sql └── 17-UserAvatar.sql
``` ```
Each script is run in order by `Database.Migrations` (DbUp) on startup. Each Each script is run in order by `Database.Migrations` (DbUp) on startup. Each

View File

@@ -1,6 +1,10 @@
# User Authentication Flow — Error Handling # 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 ```mermaid
--8<-- "web/diagrams/auth/error-handling.mmd" --8<-- "web/diagrams/auth/error-handling.mmd"

View File

@@ -1,6 +1,8 @@
# Database Schema # 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 ```mermaid
--8<-- "web/diagrams/database-schema.mmd" --8<-- "web/diagrams/database-schema.mmd"

View File

@@ -6,10 +6,12 @@
## Notes ## 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). | Environment | Details |
- **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. | **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). |
- **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. | **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. |
- **Minimal environment**: database + migrations + seed + `seaweedfs` + mailpit, no API or frontend container — for frontend development against an `API.Core` process run outside Docker. | **Prod network** (`prodnet`, bridge) | Isolated from dev/test networks. Volumes: `sqlserverdata-prod`, `nuget-cache-prod`. No seed step in production. |
- `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. | **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. |
- `.trx` results are readable by Visual Studio, Azure DevOps, and GitHub Actions. | **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. |

View File

@@ -17,12 +17,12 @@ doesn't wire up any slice; it applies schema directly.
## Project reference dependencies ## Project reference dependencies
Solid arrows are compile-time project references. Dashed arrows are the two Solid arrows are compile-time project references. Dashed arrows are the two
places slices talk to each other without a direct reference: the email places slices talk to each other without a direct reference: the email MediatR
MediatR contract routed through `Shared.Application`, and the one place that contract routed through `Shared.Application`, and the one place that pattern is
pattern is broken outright. Every DB-backed slice depends on the same three broken outright. Every DB-backed slice depends on the same three foundation
foundation projects (`Shared.Contracts`, `Domain.Entities`, projects (`Shared.Contracts`, `Domain.Entities`, `Database.Connection`) — drawn
`Database.Connection`) — drawn as one box instead of fifteen separate arrows; as one box instead of fifteen separate arrows; see the notes for what else each
see the notes for what else each of those foundation projects pulls in. of those foundation projects pulls in.
```mermaid ```mermaid
--8<-- "web/diagrams/vertical-slice/project-references.mmd" --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 - **API.Core**: Program.cs wiring only
- MediatR + `AddApplicationPart` per HTTP-exposed `Features.*` assembly - MediatR + `AddApplicationPart` per HTTP-exposed `Features.*` assembly
- Swagger/OpenAPI, health checks, JWT auth middleware, global exception filter - Swagger/OpenAPI, health checks, JWT auth middleware, global exception filter
- Only `Features.Users` and `Features.Breweries` get `AddApplicationPart` (controllers) - Only `Features.Users` and `Features.Breweries` get `AddApplicationPart`
- `Features.PhotoUpload` and `Features.Emails` scanned for MediatR handlers only (controllers)
- `Features.Locations` and `Features.Beers` not referenced by `API.Core` at all - `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 - **Feature slices**: each owns its own controller (where present),
- `Features.Users` covers auth (`Commands/Authentication`), account management (`Commands/Account`), and profile/avatar (`Commands/Profile`) commands/queries + handlers, validators, and Dapper repository
- internally namespaced `Features.Auth`, despite project name `Features.Users` - `Features.Users` covers auth (`Commands/Authentication`), account management
- `Features.Emails` and `Features.PhotoUpload` have no controller — invoked only via MediatR commands sent from other slices (`Commands/Account`), and profile/avatar (`Commands/Profile`)
- `Features.Locations` has no controller; one MediatR handler (`GetOrCreateCityHandler`) registered by `Database.Seed` only — `API.Core` never scans it - internally namespaced `Features.Auth`, despite project name
- `Features.Beers` is scaffolded (project + references only, no source files yet) `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` - 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**: - **Shared.Application**:
- `ValidationBehavior` (MediatR pipeline) - `ValidationBehavior` (MediatR pipeline)
- Cross-slice email commands (`SendRegistrationEmailCommand`, `SendResendConfirmationEmailCommand`) - Cross-slice email commands (`SendRegistrationEmailCommand`,
`SendResendConfirmationEmailCommand`)
- MediatR command contract (not project reference) - 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**: - **Domain.Entities**:
- `UserAccount`, `UserCredential`, `UserVerification`, `UserProfile`, `UserAvatar` - `UserAccount`, `UserCredential`, `UserVerification`, `UserProfile`,
`UserAvatar`
- `BreweryPost`, `BreweryPostLocation` - `BreweryPost`, `BreweryPostLocation`
- `City`, `StateProvince`, `Country` - `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 - **Database.Connection**: the shared connection-factory project
- each slice's own repository issues inline SQL via Dapper against it - 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 - **Database.Migrations**: separate console app
- `schema.dbml` + SQL scripts - `schema.dbml` + SQL scripts
- applies DDL directly - 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**: - **Database.Seed**:
- calls each slice's `AddFeatures*()` DI extension directly - 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` - no API host, no shared pipeline with `API.Core`
- **Omitted from the diagram**: - **Omitted from the diagram**:
- `Domain.Exceptions` (referenced by nearly every project directly, including all of `Shared.Contracts`, `Infrastructure.Jwt`, and each feature slice) - `Domain.Exceptions` (referenced by nearly every project directly, including
- `Infrastructure.Configuration` (the shared options-binding project referenced by `Database.Connection`, `Infrastructure.Email`, `Infrastructure.FileUpload`, and `Features.Emails` directly) all of `Shared.Contracts`, `Infrastructure.Jwt`, and each feature slice)
- both are plumbing-level dependencies of nearly everything above, and drawing them would just add a second copy of the same fan-in - `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

View File

@@ -21,8 +21,7 @@ The project uses Docker Compose to orchestrate multiple services:
- React Router website (SSR frontend) - React Router website (SSR frontend)
- Test runners - Test runners
See the [deployment diagram](diagrams/deployment.md) for visual See the [deployment diagram](diagrams/deployment.md) for visual representation.
representation.
## Docker Compose environments ## Docker Compose environments

View File

@@ -308,9 +308,9 @@ cd web
See [Generating an env file](#generating-an-env-file) above — `generate-env.sh` 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 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 `.env.dev`/`.env.test`/`.env.prod` file. A plain
web/.env.dev` works too, but leaves the placeholder secrets from the template `cp web/.env.example web/.env.dev` works too, but leaves the placeholder secrets
in place; edit them manually if you go that route. from the template in place; edit them manually if you go that route.
**Docker Compose Mapping**: **Docker Compose Mapping**:

View File

@@ -70,10 +70,12 @@ and SeaweedFS (local dev S3-compatible object storage for photo uploads).
Available endpoints: Available endpoints:
- API Swagger: http://localhost:8080/swagger | Service | URL |
- Health Check: http://localhost:8080/health | ---------------- | ----------------------------- |
- Mailpit UI: http://localhost:8025 | API Swagger | http://localhost:8080/swagger |
- SeaweedFS S3 API: http://localhost:8333 | Health Check | http://localhost:8080/health |
| Mailpit UI | http://localhost:8025 |
| SeaweedFS S3 API | http://localhost:8333 |
### 4. Start the frontend ### 4. Start the frontend

View File

@@ -16,16 +16,12 @@ Biergarten App.
The project uses a multi-layered testing approach across backend and frontend: The project uses a multi-layered testing approach across backend and frontend:
- **API.Specs** - BDD integration tests using Reqnroll (Gherkin), run against a | Suite | Purpose |
live, seeded database | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
- **Features.\*.Tests** - One unit test project per backend feature slice | **API.Specs** | BDD integration tests using Reqnroll (Gherkin), run against a live, seeded database |
(`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`, | **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) |
`Features.PhotoUpload.Tests`), covering that slice's command/query handlers, | **Storybook Vitest project** | Browser-based interaction tests for shared website stories |
with dependencies mocked via Moq (no real database required) | **Storybook Playwright suite** | Browser checks against Storybook-rendered components |
- **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) ## 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: 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 ### Feature slice unit tests
Each feature slice has its own test project, covering its command/query Each feature slice has its own test project, covering its command/query
@@ -122,6 +105,19 @@ npm install
npm run test:storybook 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**: **Purpose**:
- Verifies shared stories such as form fields, submit buttons, navbar states, - Verifies shared stories such as form fields, submit buttons, navbar states,

View File

@@ -12,11 +12,11 @@ tags:
The Biergarten API validates JSON Web Tokens (JWTs) across three token types: The Biergarten API validates JSON Web Tokens (JWTs) across three token types:
| Token Type | Lifetime | Purpose | | Token Type | Lifetime | Purpose |
| --- | --- | --- | | ----------------------- | ---------- | --------------------------- |
| **Access Tokens** | 1 hour | API authentication | | **Access Tokens** | 1 hour | API authentication |
| **Refresh Tokens** | 21 days | Obtaining new access tokens | | **Refresh Tokens** | 21 days | Obtaining new access tokens |
| **Confirmation Tokens** | 30 minutes | Email confirmation | | **Confirmation Tokens** | 30 minutes | Email confirmation |
## Components ## 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 - `GenerateAccessToken(Guid userId, string username)` - Creates 1-hour access
token token
- `GenerateRefreshToken(Guid userId, string username)` - Creates 21-day - `GenerateRefreshToken(Guid userId, string username)` - Creates 21-day refresh
refresh token token
- `GenerateConfirmationToken(Guid userId, string username)` - Creates - `GenerateConfirmationToken(Guid userId, string username)` - Creates 30-minute
30-minute confirmation token confirmation token
**Validation methods:** **Validation methods:**
- `ValidateRefreshTokenAsync(string token)` - Validates refresh tokens - `ValidateRefreshTokenAsync(string token)` - Validates refresh tokens
- `ValidateConfirmationTokenAsync(string token)` - Validates confirmation - `ValidateConfirmationTokenAsync(string token)` - Validates confirmation tokens
tokens
There is no `ValidateAccessTokenAsync` on `ITokenService`. Access-token There is no `ValidateAccessTokenAsync` on `ITokenService`. Access-token
validation doesn't go through `ITokenService` at all: `JwtAuthenticationHandler` 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 Validation failures return HTTP 401 Unauthorized. Regardless of the specific
cause (invalid signature, expired token, missing header, malformed claims), cause (invalid signature, expired token, missing header, malformed claims),
`JwtAuthenticationHandler.HandleChallengeAsync` always returns the same `JwtAuthenticationHandler.HandleChallengeAsync` always returns the same generic
generic message: `"Unauthorized: Invalid or missing authentication token"` — message: `"Unauthorized: Invalid or missing authentication token"` — it doesn't
it doesn't distinguish failure reasons in the response. distinguish failure reasons in the response.
## Token lifecycle ## Token lifecycle
### Access token lifecycle ### Access token lifecycle
| Step | Description | | Step | Description |
| --- | --- | | ---------- | ------------------------------------------------ |
| Generation | During login (1-hour validity) | | Generation | During login (1-hour validity) |
| Usage | Included in Authorization header on API requests | | Usage | Included in Authorization header on API requests |
| Validation | Validated on protected endpoints | | Validation | Validated on protected endpoints |
| Expiration | Token becomes invalid after 1 hour | | Expiration | Token becomes invalid after 1 hour |
| Refresh | Use refresh token to obtain new access token | | Refresh | Use refresh token to obtain new access token |
### Refresh token lifecycle ### Refresh token lifecycle
| Step | Description | | Step | Description |
| --- | --- | | ---------- | ------------------------------------------------ |
| Generation | During login (21-day validity) | | Generation | During login (21-day validity) |
| Storage | Client-side (secure storage) | | Storage | Client-side (secure storage) |
| Usage | Posted to `/api/auth/refresh` endpoint | | Usage | Posted to `/api/auth/refresh` endpoint |
| Validation | Validated by `ITokenService.RefreshTokenAsync()` | | Validation | Validated by `ITokenService.RefreshTokenAsync()` |
| Rotation | New refresh token issued on successful refresh | | Rotation | New refresh token issued on successful refresh |
| Expiration | Token becomes invalid after 21 days | | Expiration | Token becomes invalid after 21 days |
### Confirmation token lifecycle ### Confirmation token lifecycle
| Step | Description | | Step | Description |
| --- | --- | | ---------- | ----------------------------------------------------- |
| Generation | During user registration (30-minute validity) | | Generation | During user registration (30-minute validity) |
| Delivery | Emailed to user in confirmation link | | Delivery | Emailed to user in confirmation link |
| Usage | User clicks link, token posted to `/api/auth/confirm` | | Usage | User clicks link, token posted to `/api/auth/confirm` |
| Validation | Validated by `ConfirmUserHandler` | | Validation | Validated by `ConfirmUserHandler` |
| Completion | User account marked as confirmed | | Completion | User account marked as confirmed |
| Expiration | Token becomes invalid after 30 minutes | | Expiration | Token becomes invalid after 30 minutes |
## Testing ## Testing
@@ -277,22 +276,18 @@ All of the following live in `Features.Users.Tests`.
### Stretch goals ### Stretch goals
1. **Middleware for Access Token Validation** 1. **Middleware for Access Token Validation** - Automatically validate access
- Automatically validate access tokens on protected routes tokens on protected routes - Populate HttpContext.User from token claims -
- Populate HttpContext.User from token claims Return 401 for invalid/missing tokens
- Return 401 for invalid/missing tokens
2. **Token Denylisting** 2. **Token Denylisting** - Implement token revocation (for example, on logout) -
- Implement token revocation (for example, on logout) Store denylisted tokens in cache/database - Check the denylist during
- Store denylisted tokens in cache/database validation
- Check the denylist during validation
3. **Refresh Token Rotation Strategy** 3. **Refresh Token Rotation Strategy** - Detect token reuse (replay attacks) -
- Detect token reuse (replay attacks) Automatically invalidate entire token chain on reuse - Log suspicious
- Automatically invalidate entire token chain on reuse activity
- Log suspicious activity
4. **Structured Logging** 4. **Structured Logging** - Log token validation attempts - Track failed
- Log token validation attempts validation reasons - Alert on repeated validation failures (brute force
- Track failed validation reasons detection)
- Alert on repeated validation failures (brute force detection)