mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Update docs formatting
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 |
|
||||
| ----------------- | -------------------------------------------------------------------------------------- |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<TRequest,TResponse>`
|
||||
(`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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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**:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user