diff --git a/docs/pipeline/diagrams/current/activity.puml b/docs/pipeline/diagrams/current/activity.puml index cd3e7b2f..998a742b 100644 --- a/docs/pipeline/diagrams/current/activity.puml +++ b/docs/pipeline/diagrams/current/activity.puml @@ -66,7 +66,7 @@ note right end note |#EAF0E8|BiergartenPipelineOrchestrator| -:QueryCitiesWithCountries(); +:QueryLocations(); |#E2EBDC|JsonLoader| :JsonLoader::LoadLocations("locations.json"); @@ -118,7 +118,7 @@ while (For each EnrichedCity?) is (Remaining cities) |#E5EDE1|DataGenerator| if (Generator Mode) then (MockGenerator) :DeterministicHash & Format; - else (LlamaGenerator) + elseif (Generator Mode) then (LlamaGenerator) :prompt_directory_->Load("USER_GENERATION"); note right Resolves to USER_GENERATION.md inside --prompt-dir. @@ -133,15 +133,43 @@ while (For each EnrichedCity?) is (Remaining cities) :Append validation error to retry prompt; endif repeat while (Attempt < 3?) is (yes) + else (OpenAIGenerator) + :prompt_directory_->Load("USER_GENERATION"); + note right + Same prompt file as LlamaGenerator - content + is backend-agnostic. + end note + repeat + :CallChatCompletionsApi(system_prompt, user_prompt,\nkUserJsonSchema, max_tokens); + :ValidateUserJson(raw, user); + if (Is JSON Valid?) then (yes) + break + else (no) + :Attempt++; + :Append validation error to retry prompt; + endif + repeat while (Attempt < 2?) is (yes) + note right + Structured Outputs (response_format: + json_schema, strict: true) already + guarantees schema shape, so only 2 + attempts are needed - retries here cover + transient failures / placeholder text, + not malformed JSON. + end note endif |#EAF0E8|BiergartenPipelineOrchestrator| if (Generation successful?) then (yes) + :GetRandomCoordsWithinRange(city_centre, 5km, rng); :BuildEmail(name, used_email_local_parts); :GenerateDateOfBirth(rng); note right email and date_of_birth are generated programmatically, never LLM-authored. + Generation + coordinate jitter run inside + one try/catch per record. UserRecord.address + now carries {city, longitude, latitude}. end note |#E0EAE0|SqliteExportService| @@ -171,7 +199,7 @@ endwhile (Done) while (For each EnrichedCity?) is (Remaining cities) if (Generator Mode) then (MockGenerator) :DeterministicHash & Format; - else (LlamaGenerator) + elseif (Generator Mode) then (LlamaGenerator) :PrepareRegionContext; :prompt_directory_->Load("BREWERY_GENERATION"); note right @@ -191,10 +219,40 @@ while (For each EnrichedCity?) is (Remaining cities) max_tokens is fixed across retries — it is not raised on truncation. end note + else (OpenAIGenerator) + :PrepareRegionContext; + :prompt_directory_->Load("BREWERY_GENERATION"); + note right + Same prompt file as LlamaGenerator - content + is backend-agnostic. + end note + repeat + :CallChatCompletionsApi(system_prompt, user_prompt,\nkBreweryJsonSchema, max_tokens); + :ValidateBreweryJson(raw, brewery); + if (Is JSON Valid?) then (yes) + break + else (no) + :Attempt++; + :Append validation error to retry prompt; + endif + repeat while (Attempt < 2?) is (yes) + note right + Structured Outputs already guarantees schema + shape, so only 2 attempts are needed - retries + here cover transient failures / placeholder + text, not malformed JSON. + end note endif |#EAF0E8|BiergartenPipelineOrchestrator| if (Generation successful?) then (yes) + :GetRandomCoordsWithinRange(city_centre, 5km, rng); + note right + Generation + coordinate jitter run inside one + try/catch per record. BreweryRecord.address + now carries {city, longitude, latitude}. + end note + |#E0EAE0|SqliteExportService| :ProcessRecord(GeneratedBrewery); if (Location in cache?) then (yes) diff --git a/docs/pipeline/diagrams/current/class.puml b/docs/pipeline/diagrams/current/class.puml index 4eef0147..935274a6 100644 --- a/docs/pipeline/diagrams/current/class.puml +++ b/docs/pipeline/diagrams/current/class.puml @@ -35,12 +35,20 @@ class BiergartenPipelineOrchestrator { - generated_breweries_ : std::vector - generated_users_ : std::vector + Run() : bool - - QueryCitiesWithCountries() : std::vector + - QueryLocations() : std::vector - GenerateBreweries(cities : std::span) : void - GenerateUsers(cities : std::span) : void - - LogResults() : void + - LogResults() const : void } +note right of BiergartenPipelineOrchestrator + GenerateBreweries and GenerateUsers both call the + free function GetRandomCoordsWithinRange() + (biergarten_pipeline_orchestrator/coords.h) to jitter + a coordinate pair uniformly within 5km of the city + centre for each generated record's address. +end note + class LogLevel <> { Debug Info @@ -50,6 +58,7 @@ class LogLevel <> { class PipelinePhase <> { Startup + Enrichment UserGeneration BreweryAndBeerGeneration CheckinGeneration @@ -115,12 +124,12 @@ class HttpWebClient { } interface DataGenerator <> { - + GenerateBrewery(location : const Location&, region_context : const std::string&) : BreweryResult + + GenerateBrewery(city : const EnrichedCity&) : BreweryResult + GenerateUser(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResult } class MockGenerator { - + GenerateBrewery(location : const Location&, region_context : const std::string&) : BreweryResult + + GenerateBrewery(city : const EnrichedCity&) : BreweryResult + GenerateUser(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResult - DeterministicHash(location : const Location&) : size_t - DeterministicHash(location : const Location&, persona : const UserPersona&, name : const Name&) : size_t @@ -139,6 +148,25 @@ class LlamaGenerator { - InferFormatted(...) : std::string } +class OpenAIGenerator { + - api_key_ : std::string + - model_ : std::string + - logger_ : std::shared_ptr + - prompt_directory_ : std::unique_ptr + - web_client_ : std::unique_ptr + + GenerateBrewery(city : const EnrichedCity&) : BreweryResult + + GenerateUser(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResult + - CallChatCompletionsApi(...) : std::string +} + +note right of OpenAIGenerator + Chat Completions with Structured Outputs + (response_format: json_schema, strict: true) + guarantees schema-valid JSON, replacing + llama.cpp's GBNF grammar constraint - + no IPromptFormatter needed. +end note + interface IPromptFormatter <> { + Format(system_prompt : std::string_view, user_prompt : std::string_view) : std::string } @@ -252,10 +280,14 @@ WebClient <|.. HttpWebClient : implements DataGenerator <|.. MockGenerator : implements DataGenerator <|.. LlamaGenerator : implements +DataGenerator <|.. OpenAIGenerator : implements LlamaGenerator *-- IPromptFormatter : uses LlamaGenerator *-- IPromptDirectory : uses +OpenAIGenerator *-- IPromptDirectory : uses +OpenAIGenerator *-- WebClient : uses + IPromptFormatter <|.. Gemma4JinjaPromptFormatter : implements IPromptDirectory <|.. PromptDirectory : implements diff --git a/docs/pipeline/diagrams/current/output/activity.svg b/docs/pipeline/diagrams/current/output/activity.svg index b45b2017..858ca813 100644 --- a/docs/pipeline/diagrams/current/output/activity.svg +++ b/docs/pipeline/diagrams/current/output/activity.svg @@ -1 +1 @@ -The Biergarten Data Pipeline (Current — Synchronous Data Path)Create BoundedChannel<LogEntry> log_channelThe only concurrency in the current pipeline:a dedicated thread drains log_channel andforwards entries to spdlog for the entire run.Joined during shutdown after log_channel closes.Spawn log dispatcher thread(LogDispatcher::Run())ParseArguments(argc, argv)Log error / usage infonoAre arguments valid?yesInit OpenSSL global state & LlamaBackendStateBinds ILogger, IEnrichmentService, DataGenerator,IExportService, IPromptFormatter, WebClient basedon --mocked vs. model-backed mode.di::make_injector(...)injector.create<std::unique_ptr<BiergartenPipelineOrchestrator>>()Run() itself is synchronous — sampling, enrichment,generation, and export all happen on this thread.BiergartenPipelineOrchestrator::Run()Close log_channelJoin log dispatcher threadReturn 0Initialize SQLite exportQueryCitiesWithCountries()Lookup failed?yesnoLog warning, skip cityStore EnrichedCity{Location, region_context}Remaining citiesFor each sampled Location?DoneGenerateUsers(enriched_cities)SampleName(forenames_by_country,surnames_by_country, location.iso3166_1)Names available for country?noyesLog warning, skip citySample UserPersona (uniform random)Generation successful?yesnoBuildEmail(name, used_email_local_parts)email and date_of_birth are generatedprogrammatically, never LLM-authored.GenerateDateOfBirth(rng)Log warning "SQLite export failed"Log warning "Generation failed, skipping..."Remaining citiesFor each EnrichedCity?DoneGenerateBreweries(enriched_cities)Generation successful?yesnoData loss is prevented per-record.The pipeline continues running.Log warning "SQLite export failed"Log warning "Generation failed, skipping..."GetUtcTimestamp() from SystemDateTimeProviderBuilds a fresh biergarten_seed_<UTC datetime>.sqlite filenameAppends a numeric suffix if the timestamp already existsOpens DB ConnectionExecutes Schema DDLBegins TransactionInitialize()ProcessRecord(UserRecord)Location in cache?yesnoReuse location_idInsert Location & Cache IDInsert User (FK: location_id)yesException caught during insert?noProcessRecord(GeneratedBrewery)Location in cache?yesnoReuse location_idInsert Location & Cache IDInsert Brewery (FK: location_id)yesException caught during insert?noCommits TransactionCloses Database ConnectionFinalize()JsonLoader::LoadLocations("locations.json")--location-count defaults to 10.std::ranges::sample(all_locations, --location-count)LoadPersonas("personas.json")LoadForenamesByCountry("forenames-by-country.json")Each call is cached after its first parse.MockCuratedDataService (--mocked) returns afixed in-memory dataset instead and skipsthese three JSON files entirely.LoadSurnamesByCountry("surnames-by-country.json")WikipediaEnrichmentService fetches a generic"brewing" extract and a "beer in {country}" extract(not a city/region-specific page). MockEnrichmentService(--mocked) returns an empty string instead.GetLocationContext(loc)Generator ModeMockGeneratorLlamaGeneratorDeterministicHash & FormatResolves to USER_GENERATION.md inside --prompt-dir.prompt_directory_->Load("USER_GENERATION")Infer(system_prompt, user_prompt, max_tokens, kUserJsonGrammar)ValidateUserJson(raw, user)Is JSON Valid?yesnoAttempt++Append validation error to retry promptAttempt < 3?yesGenerator ModeMockGeneratorLlamaGeneratorDeterministicHash & FormatPrepareRegionContextResolves to BREWERY_GENERATION.md inside --prompt-dir.prompt_directory_->Load("BREWERY_GENERATION")Infer(system_prompt, user_prompt, max_tokens, kBreweryJsonGrammar)ValidateBreweryJson(raw, brewery)max_tokens is fixed across retries —it is not raised on truncation.Is JSON Valid?yesnoAttempt++Append validation error to retry promptAttempt < 3?yesRemaining citiesFor each EnrichedCity?Donemain.ccBiergartenPipelineOrchestratorSqliteExportServiceJsonLoaderIEnrichmentServiceDataGenerator \ No newline at end of file +The Biergarten Data Pipeline (Current — Synchronous Data Path)The Biergarten Data Pipeline (Current — Synchronous Data Path)Create BoundedChannel<LogEntry> log_channelThe only concurrency in the current pipeline:a dedicated thread drains log_channel andforwards entries to spdlog for the entire run.Joined during shutdown after log_channel closes.Spawn log dispatcher thread(LogDispatcher::Run())ParseArguments(argc, argv)Log error / usage infonoAre arguments valid?yesInit OpenSSL global state & LlamaBackendStateBinds ILogger, IEnrichmentService, DataGenerator,IExportService, IPromptFormatter, WebClient basedon --mocked vs. model-backed mode.di::make_injector(...)injector.create<std::unique_ptr<BiergartenPipelineOrchestrator>>()Run() itself is synchronous — sampling, enrichment,generation, and export all happen on this thread.BiergartenPipelineOrchestrator::Run()Close log_channelJoin log dispatcher threadReturn 0Initialize SQLite exportQueryLocations()Lookup failed?yesnoLog warning, skip cityStore EnrichedCity{Location, region_context}Remaining citiesFor each sampled Location?DoneGenerateUsers(enriched_cities)SampleName(forenames_by_country,surnames_by_country, location.iso3166_1)Names available for country?noyesLog warning, skip citySample UserPersona (uniform random)Generation successful?yesnoGetRandomCoordsWithinRange(city_centre, 5km, rng)BuildEmail(name, used_email_local_parts)email and date_of_birth are generatedprogrammatically, never LLM-authored.Generation + coordinate jitter run insideone try/catch per record. UserRecord.addressnow carries {city, longitude, latitude}.GenerateDateOfBirth(rng)Log warning "SQLite export failed"Log warning "Generation failed, skipping..."Remaining citiesFor each EnrichedCity?DoneGenerateBreweries(enriched_cities)Generation successful?yesnoGeneration + coordinate jitter run inside onetry/catch per record. BreweryRecord.addressnow carries {city, longitude, latitude}.GetRandomCoordsWithinRange(city_centre, 5km, rng)Data loss is prevented per-record.The pipeline continues running.Log warning "SQLite export failed"Log warning "Generation failed, skipping..."GetUtcTimestamp() from SystemDateTimeProviderBuilds a fresh biergarten_seed_<UTC datetime>.sqlite filenameAppends a numeric suffix if the timestamp already existsOpens DB ConnectionExecutes Schema DDLBegins TransactionInitialize()ProcessRecord(UserRecord)Location in cache?yesnoReuse location_idInsert Location & Cache IDInsert User (FK: location_id)yesException caught during insert?noProcessRecord(GeneratedBrewery)Location in cache?yesnoReuse location_idInsert Location & Cache IDInsert Brewery (FK: location_id)yesException caught during insert?noCommits TransactionCloses Database ConnectionFinalize()JsonLoader::LoadLocations("locations.json")--location-count defaults to 10.std::ranges::sample(all_locations, --location-count)LoadPersonas("personas.json")LoadForenamesByCountry("forenames-by-country.json")Each call is cached after its first parse.MockCuratedDataService (--mocked) returns afixed in-memory dataset instead and skipsthese three JSON files entirely.LoadSurnamesByCountry("surnames-by-country.json")WikipediaEnrichmentService fetches a generic"brewing" extract and a "beer in {country}" extract(not a city/region-specific page). MockEnrichmentService(--mocked) returns an empty string instead.GetLocationContext(loc)MockGeneratorGenerator ModeDeterministicHash & FormatLlamaGeneratorGenerator ModeOpenAIGeneratorResolves to USER_GENERATION.md inside --prompt-dir.prompt_directory_->Load("USER_GENERATION")Infer(system_prompt, user_prompt, max_tokens, kUserJsonGrammar)ValidateUserJson(raw, user)Is JSON Valid?yesnoAttempt++Append validation error to retry promptAttempt < 3?yesSame prompt file as LlamaGenerator - contentis backend-agnostic.prompt_directory_->Load("USER_GENERATION")CallChatCompletionsApi(system_prompt, user_prompt,kUserJsonSchema, max_tokens)ValidateUserJson(raw, user)Structured Outputs (response_format:json_schema, strict: true) alreadyguarantees schema shape, so only 2attempts are needed - retries here covertransient failures / placeholder text,not malformed JSON.Is JSON Valid?yesnoAttempt++Append validation error to retry promptAttempt < 2?yesMockGeneratorGenerator ModeDeterministicHash & FormatLlamaGeneratorGenerator ModeOpenAIGeneratorPrepareRegionContextResolves to BREWERY_GENERATION.md inside --prompt-dir.prompt_directory_->Load("BREWERY_GENERATION")Infer(system_prompt, user_prompt, max_tokens, kBreweryJsonGrammar)ValidateBreweryJson(raw, brewery)max_tokens is fixed across retries —it is not raised on truncation.Is JSON Valid?yesnoAttempt++Append validation error to retry promptAttempt < 3?yesPrepareRegionContextSame prompt file as LlamaGenerator - contentis backend-agnostic.prompt_directory_->Load("BREWERY_GENERATION")CallChatCompletionsApi(system_prompt, user_prompt,kBreweryJsonSchema, max_tokens)ValidateBreweryJson(raw, brewery)Structured Outputs already guarantees schemashape, so only 2 attempts are needed - retrieshere cover transient failures / placeholdertext, not malformed JSON.Is JSON Valid?yesnoAttempt++Append validation error to retry promptAttempt < 2?yesRemaining citiesFor each EnrichedCity?Donemain.ccBiergartenPipelineOrchestratorSqliteExportServiceJsonLoaderIEnrichmentServiceDataGenerator \ No newline at end of file diff --git a/docs/pipeline/diagrams/current/output/class.svg b/docs/pipeline/diagrams/current/output/class.svg index 53d928ac..b4751c3a 100644 --- a/docs/pipeline/diagrams/current/output/class.svg +++ b/docs/pipeline/diagrams/current/output/class.svg @@ -1 +1 @@ -The Biergarten Data Pipeline - Class DiagramBiergartenPipelineOrchestratorlogger_ : std::shared_ptr<ILogger>context_service_ : std::unique_ptr<IEnrichmentService>generator_ : std::unique_ptr<DataGenerator>exporter_ : std::unique_ptr<IExportService>curated_data_service_ : std::unique_ptr<ICuratedDataService>application_options_ : ApplicationOptionsgenerated_breweries_ : std::vector<BreweryRecord>generated_users_ : std::vector<UserRecord>Run() : boolQueryCitiesWithCountries() : std::vector<Location>GenerateBreweries(cities : std::span<const EnrichedCity>) : voidGenerateUsers(cities : std::span<const EnrichedCity>) : voidLogResults() : void«enumeration»LogLevelDebugInfoWarnError«enumeration»PipelinePhaseStartupUserGenerationBreweryAndBeerGenerationCheckinGenerationRatingGenerationFollowGenerationTeardownLogDTOlevel : LogLevelphase : PipelinePhasemessage : std::stringLogEntrytimestamp : std::chrono::system_clock::time_pointorigin : std::source_locationthread_id : std::thread::idlevel : LogLevelphase : PipelinePhasemessage : std::string«interface»ILoggerLog(payload : LogDTO) : voidDoLog(entry : LogEntry) : voidLogProducerchannel_ : BoundedChannel<LogEntry>&DoLog(entry : LogEntry) : voidLogDispatcherchannel_ : BoundedChannel<LogEntry>&Run() : voidToSpdlogLevel(level) : spdlog::level::level_enum«interface»IEnrichmentServiceGetLocationContext(loc : const Location&) : std::stringMockEnrichmentServiceGetLocationContext(loc : const Location&) : std::stringWikipediaEnrichmentServiceclient_ : std::unique_ptr<WebClient>extract_cache_ : std::unordered_map<std::string, std::string>GetLocationContext(loc : const Location&) : std::stringFetchExtract(query : std::string_view) : std::string«interface»WebClientGet(url : const std::string&) : std::stringUrlEncode(value : const std::string&) : std::stringHttpWebClientGet(url : const std::string&) : std::stringUrlEncode(value : const std::string&) : std::string«interface»DataGeneratorGenerateBrewery(location : const Location&, region_context : const std::string&) : BreweryResultGenerateUser(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResultMockGeneratorGenerateBrewery(location : const Location&, region_context : const std::string&) : BreweryResultGenerateUser(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResultDeterministicHash(location : const Location&) : size_tDeterministicHash(location : const Location&, persona : const UserPersona&, name : const Name&) : size_tLlamaGeneratormodel_ : ModelHandlecontext_ : ContextHandleprompt_formatter_ : std::unique_ptr<IPromptFormatter>prompt_directory_ : std::unique_ptr<IPromptDirectory>rng_ : std::mt19937GenerateBrewery(...) : BreweryResultGenerateUser(...) : UserResultLoad(model_path : const std::string&) : voidInfer(...) : std::stringInferFormatted(...) : std::string«interface»IPromptFormatterFormat(system_prompt : std::string_view, user_prompt : std::string_view) : std::stringGemma4JinjaPromptFormatterFormat(system_prompt : std::string_view, user_prompt : std::string_view) : std::string«interface»IPromptDirectoryLoad(key : std::string_view) : std::stringPromptDirectoryprompt_dir_ : std::filesystem::pathcache_ : std::unordered_map<std::string, std::string>Load(key : std::string_view) : std::string«interface»ICuratedDataServiceLoadLocations(filepath : const std::filesystem::path&) : const std::vector<Location>&LoadPersonas(filepath : const std::filesystem::path&) : const std::vector<UserPersona>&LoadForenamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, forename_list>&LoadSurnamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, surname_list>&JsonLoadercache_ : cacheLoadLocations(filepath : const std::filesystem::path&) : const std::vector<Location>&LoadPersonas(filepath : const std::filesystem::path&) : const std::vector<UserPersona>&LoadForenamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, forename_list>&LoadSurnamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, surname_list>&Each Load* method memoizes its result incache_ on first call; later calls on thesame instance return the cached valuewithout re-parsing.MockCuratedDataServicelocations_ : std::vector<Location>personas_ : std::vector<UserPersona>forenames_by_country_ : std::unordered_map<std::string, forename_list>surnames_by_country_ : std::unordered_map<std::string, surname_list>LoadLocations(filepath : const std::filesystem::path&) : const std::vector<Location>&LoadPersonas(filepath : const std::filesystem::path&) : const std::vector<UserPersona>&LoadForenamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, forename_list>&LoadSurnamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, surname_list>&Fixed 4-location / 3-persona dataset(US, DE, FR, BE) used in --mocked runs;filepath arguments are ignored.«interface»IExportServiceInitialize() : voidProcessRecord(brewery : const BreweryRecord&) : uint64_tProcessRecord(user : const UserRecord&) : uint64_tFinalize() : voidSqliteExportServicedate_time_provider_ : std::unique_ptr<IDateTimeProvider>run_timestamp_utc_ : std::stringdatabase_path_ : std::filesystem::pathdb_handle_ : SqliteDatabaseHandleinsert_location_stmt_ : SqliteStatementHandleinsert_brewery_stmt_ : SqliteStatementHandleinsert_user_stmt_ : SqliteStatementHandletransaction_open_ : boollocation_cache_ : std::unordered_map<std::string, sqlite3_int64>Initialize() : voidProcessRecord(brewery : const BreweryRecord&) : uint64_tProcessRecord(user : const UserRecord&) : uint64_tFinalize() : voidInitializeSchema() : voidResolveLocationId(location : const Location&) : sqlite3_int64«interface»IDateTimeProviderGetUtcTimestamp() : std::stringSystemDateTimeProviderGetUtcTimestamp() : std::stringownsownsownsownsownsimplementsemitsconsumesimplementsimplementsownsimplementsimplementsimplementsusesusesimplementsimplementsimplementsimplementsimplementsownsimplements \ No newline at end of file +The Biergarten Data Pipeline - Class DiagramThe Biergarten Data Pipeline - Class DiagramBiergartenPipelineOrchestratorlogger_ : std::shared_ptr<ILogger>context_service_ : std::unique_ptr<IEnrichmentService>generator_ : std::unique_ptr<DataGenerator>exporter_ : std::unique_ptr<IExportService>curated_data_service_ : std::unique_ptr<ICuratedDataService>application_options_ : ApplicationOptionsgenerated_breweries_ : std::vector<BreweryRecord>generated_users_ : std::vector<UserRecord>Run() : boolQueryLocations() : std::vector<City>GenerateBreweries(cities : std::span<const EnrichedCity>) : voidGenerateUsers(cities : std::span<const EnrichedCity>) : voidLogResults() const : voidGenerateBreweries and GenerateUsers both call thefree function GetRandomCoordsWithinRange()(biergarten_pipeline_orchestrator/coords.h) to jittera coordinate pair uniformly within 5km of the citycentre for each generated record's address.«enumeration»LogLevelDebugInfoWarnError«enumeration»PipelinePhaseStartupEnrichmentUserGenerationBreweryAndBeerGenerationCheckinGenerationRatingGenerationFollowGenerationTeardownLogDTOlevel : LogLevelphase : PipelinePhasemessage : std::stringLogEntrytimestamp : std::chrono::system_clock::time_pointorigin : std::source_locationthread_id : std::thread::idlevel : LogLevelphase : PipelinePhasemessage : std::string«interface»ILoggerLog(payload : LogDTO) : voidDoLog(entry : LogEntry) : voidLogProducerchannel_ : BoundedChannel<LogEntry>&DoLog(entry : LogEntry) : voidLogDispatcherchannel_ : BoundedChannel<LogEntry>&Run() : voidToSpdlogLevel(level) : spdlog::level::level_enum«interface»IEnrichmentServiceGetLocationContext(loc : const Location&) : std::stringMockEnrichmentServiceGetLocationContext(loc : const Location&) : std::stringWikipediaEnrichmentServiceclient_ : std::unique_ptr<WebClient>extract_cache_ : std::unordered_map<std::string, std::string>GetLocationContext(loc : const Location&) : std::stringFetchExtract(query : std::string_view) : std::string«interface»WebClientGet(url : const std::string&) : std::stringUrlEncode(value : const std::string&) : std::stringHttpWebClientGet(url : const std::string&) : std::stringUrlEncode(value : const std::string&) : std::string«interface»DataGeneratorGenerateBrewery(city : const EnrichedCity&) : BreweryResultGenerateUser(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResultMockGeneratorGenerateBrewery(city : const EnrichedCity&) : BreweryResultGenerateUser(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResultDeterministicHash(location : const Location&) : size_tDeterministicHash(location : const Location&, persona : const UserPersona&, name : const Name&) : size_tLlamaGeneratormodel_ : ModelHandlecontext_ : ContextHandleprompt_formatter_ : std::unique_ptr<IPromptFormatter>prompt_directory_ : std::unique_ptr<IPromptDirectory>rng_ : std::mt19937GenerateBrewery(...) : BreweryResultGenerateUser(...) : UserResultLoad(model_path : const std::string&) : voidInfer(...) : std::stringInferFormatted(...) : std::stringOpenAIGeneratorapi_key_ : std::stringmodel_ : std::stringlogger_ : std::shared_ptr<ILogger>prompt_directory_ : std::unique_ptr<IPromptDirectory>web_client_ : std::unique_ptr<WebClient>GenerateBrewery(city : const EnrichedCity&) : BreweryResultGenerateUser(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResultCallChatCompletionsApi(...) : std::stringChat Completions with Structured Outputs(response_format: json_schema, strict: true)guarantees schema-valid JSON, replacingllama.cpp's GBNF grammar constraint -no IPromptFormatter needed.«interface»IPromptFormatterFormat(system_prompt : std::string_view, user_prompt : std::string_view) : std::stringGemma4JinjaPromptFormatterFormat(system_prompt : std::string_view, user_prompt : std::string_view) : std::string«interface»IPromptDirectoryLoad(key : std::string_view) : std::stringPromptDirectoryprompt_dir_ : std::filesystem::pathcache_ : std::unordered_map<std::string, std::string>Load(key : std::string_view) : std::string«interface»ICuratedDataServiceLoadLocations(filepath : const std::filesystem::path&) : const std::vector<Location>&LoadPersonas(filepath : const std::filesystem::path&) : const std::vector<UserPersona>&LoadForenamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, forename_list>&LoadSurnamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, surname_list>&JsonLoadercache_ : cacheLoadLocations(filepath : const std::filesystem::path&) : const std::vector<Location>&LoadPersonas(filepath : const std::filesystem::path&) : const std::vector<UserPersona>&LoadForenamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, forename_list>&LoadSurnamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, surname_list>&Each Load* method memoizes its result incache_ on first call; later calls on thesame instance return the cached valuewithout re-parsing.MockCuratedDataServicelocations_ : std::vector<Location>personas_ : std::vector<UserPersona>forenames_by_country_ : std::unordered_map<std::string, forename_list>surnames_by_country_ : std::unordered_map<std::string, surname_list>LoadLocations(filepath : const std::filesystem::path&) : const std::vector<Location>&LoadPersonas(filepath : const std::filesystem::path&) : const std::vector<UserPersona>&LoadForenamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, forename_list>&LoadSurnamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, surname_list>&Fixed 4-location / 3-persona dataset(US, DE, FR, BE) used in --mocked runs;filepath arguments are ignored.«interface»IExportServiceInitialize() : voidProcessRecord(brewery : const BreweryRecord&) : uint64_tProcessRecord(user : const UserRecord&) : uint64_tFinalize() : voidSqliteExportServicedate_time_provider_ : std::unique_ptr<IDateTimeProvider>run_timestamp_utc_ : std::stringdatabase_path_ : std::filesystem::pathdb_handle_ : SqliteDatabaseHandleinsert_location_stmt_ : SqliteStatementHandleinsert_brewery_stmt_ : SqliteStatementHandleinsert_user_stmt_ : SqliteStatementHandletransaction_open_ : boollocation_cache_ : std::unordered_map<std::string, sqlite3_int64>Initialize() : voidProcessRecord(brewery : const BreweryRecord&) : uint64_tProcessRecord(user : const UserRecord&) : uint64_tFinalize() : voidInitializeSchema() : voidResolveLocationId(location : const Location&) : sqlite3_int64«interface»IDateTimeProviderGetUtcTimestamp() : std::stringSystemDateTimeProviderGetUtcTimestamp() : std::stringownsownsownsownsownsimplementsemitsconsumesimplementsimplementsownsimplementsimplementsimplementsimplementsusesusesusesusesimplementsimplementsimplementsimplementsimplementsownsimplements \ No newline at end of file diff --git a/docs/website/diagrams-out/authentication-flow.svg b/docs/website/diagrams-out/authentication-flow.svg index 724bf07b..e90a7d3e 100644 --- a/docs/website/diagrams-out/authentication-flow.svg +++ b/docs/website/diagrams-out/authentication-flow.svg @@ -1 +1 @@ -User Authentication Flow - ExpandedUser Authentication Flow - ExpandedHandler LayerInfrastructure LayerRepository / Cross-sliceAPIAPIMediatR pipelineMediatR pipelineMediatR pipelineRegisterUserHandlerLoginHandlerTokenServiceTokenServiceTokenServiceTokenServiceTokenServiceArgon2Argon2JWTJWTJWTJWTJWTEmailTemplateAuthRepositoryAuthRepositoryAuthRepositoryAuthRepositoryAuthRepositoryEmailDispatcherSQL ServerSQL ServerSQL ServerSQL ServerSQL ServerSQL ServerUserAPIMediatR pipelineRegisterUserHandlerLoginHandlerTokenServiceArgon2JWTEmailTemplateAuthRepositoryEmailDispatcherSQL ServerUserUserAPIControllerAPIControllerMediatR pipeline(ValidationBehavior)MediatR pipeline(ValidationBehavior)RegisterUserHandlerRegisterUserHandlerLoginHandlerLoginHandlerTokenServiceTokenServiceArgon2InfrastructureArgon2InfrastructureJWTInfrastructureJWTInfrastructureEmailProviderEmailProviderTemplateProviderTemplateProviderAuthRepositoryAuthRepositoryEmailDispatcher(Features.Emails)EmailDispatcher(Features.Emails)SQL ServerSQL ServerAPIAPIMediatR pipelineMediatR pipelineMediatR pipelineRegisterUserHandlerLoginHandlerTokenServiceTokenServiceTokenServiceTokenServiceTokenServiceArgon2Argon2JWTJWTJWTJWTJWTEmailTemplateAuthRepositoryAuthRepositoryAuthRepositoryAuthRepositoryAuthRepositoryEmailDispatcherSQL ServerSQL ServerSQL ServerSQL ServerSQL ServerSQL ServerRegistration FlowPOST /api/auth/register{username, firstName, lastName,email, dateOfBirth, password}Send(RegisterUserCommand)ValidationBehavior runs RegisterUserValidator(FluentValidation) before the handler:- Username: 3-64 chars, alphanumeric + [._-]- Email: valid format, max 128 chars- Password: min 8 chars, uppercase,\n lowercase, number, special char- DateOfBirth: must be 19+ years oldalt[Validation fails]throwsFluentValidation.ValidationException400 Bad Request{message: "Validation failed",errors: {...}}[Validation succeeds]Handle(command)GetUserByUsernameAsync(username)SELECT UserAccountID,Username, ...FROM dbo.UserAccountWHERE Username = @Usernamenull (user doesn't exist)GetUserByEmailAsync(email)SELECT UserAccountID,Username, ...FROM dbo.UserAccountWHERE Email = @Emailnull (email doesn't exist)alt[Username or email already exists]throw ConflictExceptionpropagate409 Conflict"Username or email alreadyexists"[Username and email are free]Hash(password)Argon2id parameters:- Salt: 16 bytes (128-bit)- Memory: 64MB- Iterations: 4- Parallelism: CPU count- Hash output: 32 bytesGenerate random salt(16 bytes)Hash password withArgon2id algorithm"base64(salt):base64(hash)"RegisterUserAsync(userAccount)BEGIN TRANSACTIONINSERT INTO dbo.UserAccount(Username, FirstName,LastName,DateOfBirth, Email)OUTPUTINSERTED.UserAccountIDUserAccountId (GUID)INSERT INTO dbo.UserCredential(UserAccountId, Hash)COMMIT TRANSACTIONAll three statements run via Dapper'sExecuteScalarAsync/ExecuteAsync againsta single DbTransaction, not a storedprocedure - no manual parameter bindingSELECT UserAccountID,Username, ...FROM dbo.UserAccountWHERE UserAccountID =@UserAccountIdRound trip to load the row theINSERT just createdUserAccount rowUserAccount entityGenerateAccessToken(user)GenerateJwt(userId, username,expiry)JWT Configuration:- Algorithm: HS256- Expires: 1 hour- Claims:* sub: userId* unique_name: username* iat / jtiCreate JWT with claimsSign with secret keyAccess TokenAccess TokenGenerateRefreshToken(user)GenerateJwt(userId, username,expiry)Refresh Token:- Expires: 21 days (504 hours)- Same structure as access token,\n signed with a separate secretRefresh TokenRefresh TokenGenerateConfirmationToken(user)GenerateJwt(userId, username,expiry)Confirmation Token:- Expires: 30 minutes (0.5 hours)- Signed with a third, separate secretConfirmation TokenConfirmation TokenSend(SendRegistrationEmailCommand)(Shared.Application cross-slicecontract)SendRegistrationEmailAsync(  firstName, email,confirmationToken)Routed to Features.Emails'SendRegistrationEmailHandler,the only cross-slice call inthe backendRenderUserRegisteredEmailAsync(  firstName, confirmationLink)Razor Component:- Header with branding- Welcome message- Confirmation button- FooterRender Razor componentto HTMLHTML email contentSendAsync(email, subject, body)SMTP Configuration (MailKit):- Host/port: from env (SMTP_HOST/SMTP_PORT)- TLS: StartTLS when SMTP_USE_SSL=true- Auth: username/password, if configuredBuild MIME messageConnect, authenticate,send, disconnectSuccess / throwsalt[Email sent successfully]emailSent = true[mediator.Send throws]catch(Exception),log to stderr,emailSent = falseRegistrationPayload(  userAccountId, username,  refreshToken, accessToken,  confirmationEmailSent)RegistrationPayload201 Created{  message: "User registeredsuccessfully.",  payload: {    userAccountId, username,    refreshToken, accessToken,    confirmationEmailSent  }}Login FlowPOST /api/auth/login{username, password}Send(LoginCommand)ValidationBehavior runs LoginValidator:username and password must both be non-emptyalt[Validation fails]throwsFluentValidation.ValidationException400 Bad Request{message: "Validation failed",errors: {...}}[Validation succeeds]Handle(command)GetUserByUsernameAsync(username)SELECT UserAccountID,Username, ...FROM dbo.UserAccountWHERE Username = @UsernameUserAccount row or nullalt[User not found]throw UnauthorizedException"Invalid username or password."propagate401 Unauthorized[User found]GetActiveCredentialByUserAccountIdAsync(userId)SELECT UserCredentialId,UserAccountId,Hash, CreatedAt, RowVersionFROM dbo.UserCredentialWHERE UserAccountId =@UserAccountId  AND IsRevoked = 0UserCredential row or nullalt[No active credential]throw UnauthorizedExceptionpropagate401 Unauthorized[Active credential found]Verify(password, storedHash)1. Split stored hash: "salt:hash"2. Extract salt3. Hash provided password\n with same salt4. Constant-time comparison\n (CryptographicOperations.FixedTimeEquals)Parse salt from stored hashHash provided passwordwith extracted saltFixedTimeEquals(  computed, stored)true/falsealt[Password invalid]throw UnauthorizedExceptionpropagate401 Unauthorized[Password valid]GenerateAccessToken(user)GenerateJwt(...)Access TokenAccess TokenGenerateRefreshToken(user)GenerateJwt(...)Refresh TokenRefresh TokenLoginPayload(  userAccountId, username,  refreshToken, accessToken)LoginPayload200 OK{  message: "Logged insuccessfully.",  payload: {    userAccountId, username,    refreshToken, accessToken  }}Error Handling (Global Exception Filter)API.Core's GlobalExceptionFilter catches:- FluentValidation.ValidationException -> 400 Bad Request\n {message, errors} (grouped by property)- ConflictException -> 409 Conflict- NotFoundException -> 404 Not Found- UnauthorizedException -> 401 Unauthorized- ForbiddenException -> 403 Forbidden- Domain.Exceptions.ValidationException -> 400 Bad Request- Microsoft.Data.SqlClient.SqlException -> 503 Service Unavailable- All others -> 500 Internal Server Error \ No newline at end of file +User Authentication Flow - ExpandedUser Authentication Flow - ExpandedHandler LayerInfrastructure LayerRepository / Cross-sliceAPIAPIAPIAPIAPIAPIAPIAPIAPIAPIMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineRegisterUserHandlerLoginHandlerRefreshTokenHandlerConfirmUserHandlerResendConfirmationEmailHandlerUpdateUsernameHandlerUpdateEmailHandlerUpdatePasswordHandlerUpdateProfileHandlerDeleteAccountHandlerUserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.TokenServiceTokenServiceTokenServiceTokenServiceTokenServiceTokenServiceTokenServiceTokenServiceArgon2PasswordHasherArgon2PasswordHasherArgon2PasswordHasherArgon2PasswordHasherJWTJWTJWTJWTJWTJWTJWTEmailTemplateDapperUserStoreDapperUserStoreDapperUserStoreDapperUserStoreDapperUserStoreDapperUserStoreDapperUserStoreDapperUserStoreDapperUserStoreDapperUserStoreDapperUserStoreEmailDispatcherEmailDispatcherSQL ServerSQL ServerSQL ServerSQL ServerSQL ServerSQL ServerSQL ServerSQL ServerUserAPIMediatR pipelineRegisterUserHandlerLoginHandlerRefreshTokenHandlerConfirmUserHandlerResendConfirmationEmailHandlerUpdateUsernameHandlerUpdateEmailHandlerUpdatePasswordHandlerUpdateProfileHandlerDeleteAccountHandlerUserManager.ApplicationUser.TokenServiceArgon2PasswordHasherJWTEmailTemplateDapperUserStoreEmailDispatcherSQL ServerUserUserAPIControllerAPIControllerMediatR pipeline(ValidationBehavior)MediatR pipeline(ValidationBehavior)RegisterUserHandlerRegisterUserHandlerLoginHandlerLoginHandlerRefreshTokenHandlerRefreshTokenHandlerConfirmUserHandlerConfirmUserHandlerResendConfirmationEmailHandlerResendConfirmationEmailHandlerUpdateUsernameHandlerUpdateUsernameHandlerUpdateEmailHandlerUpdateEmailHandlerUpdatePasswordHandlerUpdatePasswordHandlerUpdateProfileHandlerUpdateProfileHandlerDeleteAccountHandlerDeleteAccountHandlerUserManager<ApplicationUser>UserManager<ApplicationUser>TokenServiceTokenServiceArgon2PasswordHasherArgon2PasswordHasherJWTInfrastructureJWTInfrastructureEmailProviderEmailProviderTemplateProviderTemplateProviderDapperUserStore(IUserStore/IUserPasswordStore/IUserEmailStore)DapperUserStore(IUserStore/IUserPasswordStore/IUserEmailStore)EmailDispatcher(Features.Emails)EmailDispatcher(Features.Emails)SQL ServerSQL ServerAPIAPIAPIAPIAPIAPIAPIAPIAPIAPIMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineMediatR pipelineRegisterUserHandlerLoginHandlerRefreshTokenHandlerConfirmUserHandlerResendConfirmationEmailHandlerUpdateUsernameHandlerUpdateEmailHandlerUpdatePasswordHandlerUpdateProfileHandlerDeleteAccountHandlerUserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.UserManager.ApplicationUser.TokenServiceTokenServiceTokenServiceTokenServiceTokenServiceTokenServiceTokenServiceTokenServiceArgon2PasswordHasherArgon2PasswordHasherArgon2PasswordHasherArgon2PasswordHasherJWTJWTJWTJWTJWTJWTJWTEmailTemplateDapperUserStoreDapperUserStoreDapperUserStoreDapperUserStoreDapperUserStoreDapperUserStoreDapperUserStoreDapperUserStoreDapperUserStoreDapperUserStoreDapperUserStoreEmailDispatcherEmailDispatcherSQL ServerSQL ServerSQL ServerSQL ServerSQL ServerSQL ServerSQL ServerSQL ServerASP.NET Core Identity's UserManager<TUser>replaces the deleted AuthRepository: handlersno longer issue SQL or call Argon2 directly,they delegate to UserManager, which in turncalls DapperUserStore (persistence) andArgon2PasswordHasher (IPasswordHasher<TUser>).Registration FlowPOST /api/auth/register{username, firstName,lastName,email, dateOfBirth, password}Send(RegisterUserCommand)ValidationBehavior runs RegisterUserValidator(FluentValidation) before the handler:- Username: 3-64 chars, alphanumeric + [._-]- Email: valid format, max 128 chars- Password: min 8 chars, uppercase,\n lowercase, number, special char- DateOfBirth: must be 19+ years oldalt[Validation fails]throwsFluentValidation.ValidationException400 Bad Request{message: "Validation failed",errors: {...}}[Validation succeeds]Handle(command)user = new UserAccount{...}.ToApplicationUser()CreateAsync(user, password)HashPassword(user, password)Delegates to IPasswordInfrastructure.Hash:Argon2id parameters:- Salt: 16 bytes (128-bit)- Memory: 64MB- Iterations: 4- Parallelism: CPU count- Hash output: 32 bytes"base64(salt):base64(hash)"SetPasswordHashAsync(user,hash)user.Id is still empty at this point -only sets the in-memory hash. CreateAsyncwrites it to dbo.UserCredential itself.FindByNameAsync(username)SELECT UserAccountID,Username, ...FROM dbo.UserAccountWHERE Username =@Usernamenull (user doesn't exist)FindByEmailAsync(email)SELECT UserAccountID,Username, ...FROM dbo.UserAccountWHERE Email = @Emailnull (email doesn't exist)Default UserManager validators checkuniqueness before CreateAsync is called;DapperUserStore.CreateAsync also guardsagainst a genuine race via a caughtduplicate-key SqlException (2601/2627).CreateAsync(user)BEGIN TRANSACTIONINSERT INTO dbo.UserAccount(Username, FirstName,LastName,DateOfBirth, Email)OUTPUTINSERTED.UserAccountIDUserAccountId (GUID)INSERT INTOdbo.UserCredential(UserAccountId, Hash)COMMIT TRANSACTIONBoth statements run via Dapper'sExecuteScalarAsync/ExecuteAsync againsta single DbTransaction, not a storedprocedure - no manual parameter bindingIdentityResult.Success(user.Id set)IdentityResultalt[!result.Succeeded]throw ConflictException(DuplicateUserName/DuplicateEmailcodes ->"Username or email alreadyexists";otherwise joined errordescriptions)propagate409 Conflict"Username or email alreadyexists"[result.Succeeded]GenerateAccessToken(user)GenerateJwt(userId, username,expiry)JWT Configuration:- Algorithm: HS256- Expires: 1 hour- Claims:* sub: userId* unique_name: username* iat / jtiCreate JWT with claimsSign with secret keyAccess TokenAccess TokenGenerateRefreshToken(user)GenerateJwt(userId, username,expiry)Refresh Token:- Expires: 21 days (504 hours)- Same structure as access token,\n signed with a separate secretRefresh TokenRefresh TokenGenerateConfirmationToken(user)GenerateJwt(userId, username,expiry)Confirmation Token:- Expires: 30 minutes (0.5 hours)- Signed with a third, separate secretConfirmation TokenConfirmation TokenSend(SendRegistrationEmailCommand)(Shared.Application cross-slicecontract)SendRegistrationEmailAsync(  firstName, email,confirmationToken)Routed to Features.Emails'SendRegistrationEmailHandler,the only cross-slice call inthe backendRenderUserRegisteredEmailAsync(  firstName, confirmationLink)Razor Component:- Header with branding- Welcome message- Confirmation button- FooterRender Razor componentto HTMLHTML email contentSendAsync(email, subject, body)SMTP Configuration (MailKit):- Host/port: from env (SMTP_HOST/SMTP_PORT)- TLS: StartTLS when SMTP_USE_SSL=true- Auth: username/password, if configuredBuild MIME messageConnect, authenticate,send, disconnectSuccess / throwsalt[Email sent successfully]emailSent = true[mediator.Send throws]catch(Exception),log to stderr,emailSent = falseRegistrationPayload(  userAccountId, username,  refreshToken, accessToken,  confirmationEmailSent)RegistrationPayload201 Created{  message: "User registeredsuccessfully.",  payload: {    userAccountId, username,    refreshToken, accessToken,    confirmationEmailSent  }}Login FlowPOST /api/auth/login{username, password}Send(LoginCommand)ValidationBehavior runs LoginValidator:username and password must both be non-emptyalt[Validation fails]throwsFluentValidation.ValidationException400 Bad Request{message: "Validation failed",errors: {...}}[Validation succeeds]Handle(command)FindByNameAsync(username)FindByNameAsync(username)SELECT UserAccountID,Username, ...FROM dbo.UserAccountWHERE Username =@UsernameUserAccount row or nullHydrateAsync(load activeUserCredential.Hash,UserVerification ->EmailConfirmed)ApplicationUser or nullApplicationUser or nullalt[User not found]throw UnauthorizedException"Invalid username or password."propagate401 Unauthorized[User found]CheckPasswordAsync(user,password)HasPasswordAsync short-circuits to false(no active UserCredential was hydrated)without calling the hasher; otherwisedelegates to Argon2PasswordHasher.VerifyHashedPassword(  user, user.PasswordHash,password)Delegates to IPasswordInfrastructure.Verify:1. Split stored hash: "salt:hash"2. Hash provided password with the same salt3. Constant-time comparison\n (CryptographicOperations.FixedTimeEquals)Success/Failedtrue/falsealt[Password invalid]throw UnauthorizedExceptionpropagate401 Unauthorized[Password valid]GenerateAccessToken(user)GenerateJwt(...)Access TokenAccess TokenGenerateRefreshToken(user)GenerateJwt(...)Refresh TokenRefresh TokenLoginPayload(  userAccountId, username,  refreshToken, accessToken)LoginPayload200 OK{  message: "Logged insuccessfully.",  payload: {    userAccountId, username,    refreshToken, accessToken  }}Refresh Token FlowPOST /api/auth/refreshHeader: X-Refresh-Token:<token>Send(RefreshTokenCommand(refreshToken))ValidationBehavior runs RefreshTokenValidator:refreshToken must be non-empty. Anonymousendpoint - no Bearer token required.alt[Validation fails]throwsFluentValidation.ValidationException400 Bad Request{message: "Validation failed",errors: {...}}[Validation succeeds]Handle(command)RefreshTokenAsync(refreshToken)ValidateRefreshTokenAsync(refreshToken)Validates signature/expiry against therefresh token secret, extracts userId/username claims.ValidatedToken or throwsalt[Token invalid or expired]throw UnauthorizedExceptionpropagatepropagate401 Unauthorized[Token valid]FindByIdAsync(validated.UserId)FindByIdAsync(userId)SELECT UserAccountID,Username, ...FROM dbo.UserAccountWHERE UserAccountID = @Idrow or nullApplicationUser or nullApplicationUser or nullalt[User not found]throw UnauthorizedException"User account not found"propagatepropagate401 Unauthorized[User found]GenerateAccessToken(user)GenerateRefreshToken(user)new access + refresh tokensRefreshTokenResultLoginPayload(  userId, username,  refreshToken, accessToken)LoginPayload200 OK{message: "Token refreshedsuccessfully.",payload: {...}}Confirm User FlowPOST/api/auth/confirm?token=<token>(requires Bearer access token)Send(ConfirmUserCommand(token))Handle(command)ValidateConfirmationTokenAsync(token)Validate against confirmationtoken secret, extract userIdValidatedToken or throwsValidatedTokenalt[Token invalid or expired]throw UnauthorizedExceptionpropagate401 Unauthorized[Token valid]FindByIdAsync(validatedToken.UserId)FindByIdAsync(userId)SELECT ...FROM dbo.UserAccountWHERE UserAccountID = @Idrow or nullApplicationUser or nullApplicationUser or nullalt[User not found]throw UnauthorizedException"User account not found"propagate401 Unauthorized[User found]SetEmailConfirmedAsync(user,true)IUserEmailStore<ApplicationUser> is injecteddirectly into the handler (not via UserManager),setting the confirmed flag in-memory here.UpdateAsync(user)UpdateAsync(user)UPDATE dbo.UserAccount SET ...INSERT INTOdbo.UserVerification(UserAccountId,VerificationDateTime)Idempotent: skipped (duplicate-keyexception swallowed) if a verificationrow already exists.IdentityResult.SuccessIdentityResultConfirmationPayload(  user.Id, DateTime.UtcNow)ConfirmationPayload200 OK{message: "User with ID {id} isconfirmed.",payload: {...}}Resend Confirmation Email FlowPOST/api/auth/confirm/resend?userId=<id>(requires Bearer access token)Send(ResendConfirmationEmailCommand(userId))Handle(command)FindByIdAsync(userId)FindByIdAsync(userId)SELECT ...FROM dbo.UserAccountWHERE UserAccountID = @Idrow or nullApplicationUser or nullApplicationUser or nullalt[User not found]return silently(prevents user enumeration)[User found]IsEmailConfirmedAsync(user)boolalt[Already confirmed]return silently (no-op)[Not yet confirmed]GenerateConfirmationToken(user)confirmation tokenSend(SendResendConfirmationEmailCommand)SendResendConfirmationEmailAsync(  firstName, email,confirmationToken)Render email(same Razor pipeline asregistration)SendAsync(email, subject, body)(void)(void)200 OK{message: "confirmation emailhas been resent"}Always 200 OK regardless of outcome above -same user-enumeration precaution as a not-founduser or an already-confirmed account.Update Username FlowPATCH /api/auth/username{newUsername}(requires Bearer access token)Send(UpdateUsernameCommand(  GetAuthenticatedUserId(),newUsername))ValidationBehavior runs UpdateUsernameValidator(same format rules as registration)alt[Validation fails]throwsFluentValidation.ValidationException400 Bad Request[Validation succeeds]Handle(command)FindByIdAsync(userAccountId)FindByIdAsync(userAccountId)SELECT ...FROM dbo.UserAccountWHERE UserAccountID = @Idrow or nullApplicationUser or nullApplicationUser or nullalt[Account not found]throw NotFoundExceptionpropagate404 Not Found[Account found]SetUserNameAsync(user,newUsername)FindByNameAsync(newUsername)Uniqueness check before the UPDATE.UpdateAsync(user)UPDATE dbo.UserAccountSET Username = @UserName, ...IdentityResultIdentityResultalt[!result.Succeeded]throw ConflictException(DuplicateUserName code ->"Username already exists")propagate409 Conflict[result.Succeeded]UpdateUsernamePayload(  user.Id, user.UserName)UpdateUsernamePayload200 OK{message: "Username updatedsuccessfully.",payload: {...}}Update Email FlowPATCH /api/auth/email{newEmail}(requires Bearer access token)Send(UpdateEmailCommand(  GetAuthenticatedUserId(),newEmail))ValidationBehavior runs UpdateEmailValidator(same format rules as registration)alt[Validation fails]throwsFluentValidation.ValidationException400 Bad Request[Validation succeeds]Handle(command)FindByIdAsync(userAccountId)FindByIdAsync(userAccountId)SELECT ...ApplicationUser or nullApplicationUser or nullalt[Account not found]throw NotFoundExceptionpropagate404 Not Found[Account found]SetEmailAsync(user, newEmail)FindByEmailAsync(newEmail)Uniqueness check. Also resetsuser.EmailConfirmed = false -the caller must re-confirm viaPOST /api/auth/confirm/resend.UpdateAsync(user)UPDATE dbo.UserAccountSET Email = @Email, ...DELETE FROMdbo.UserVerificationWHERE UserAccountID = @IdIdentityResultIdentityResultalt[!result.Succeeded]throw ConflictException(DuplicateEmail code ->"Email address already exists")propagate409 Conflict[result.Succeeded]UpdateEmailPayload(  user.Id, user.Email,  user.EmailConfirmed = false)UpdateEmailPayload200 OK{message: "Email updatedsuccessfully.",payload: {...}}Update Password FlowPATCH /api/auth/password{currentPassword,newPassword}(requires Bearer access token)Send(UpdatePasswordCommand(  GetAuthenticatedUserId(),  currentPassword,newPassword))ValidationBehavior runs UpdatePasswordValidator(same password strength rules as registration)alt[Validation fails]throwsFluentValidation.ValidationException400 Bad Request[Validation succeeds]Handle(command)FindByIdAsync(userAccountId)FindByIdAsync(userAccountId)ApplicationUser or nullApplicationUser or nullalt[Account not found]throw NotFoundExceptionpropagate404 Not Found[Account found]ChangePasswordAsync(  user, currentPassword,newPassword)VerifyHashedPassword(  user, user.PasswordHash,currentPassword)Success/Failedalt[Current password mismatch]IdentityResult.Failed(PasswordMismatch)[Current password matches]HashPassword(user,newPassword)new hashSetPasswordHashAsync(user,newHash)UPDATE dbo.UserCredentialSET IsRevoked = 1, RevokedAt =GETDATE()WHERE UserAccountId = @IdAND IsRevoked = 0INSERT INTOdbo.UserCredential(UserAccountId, Hash)Revoke-then-insert runs inside oneDbTransaction (credential rotation).(void)IdentityResult.Successalt[Current password is incorrect]throw UnauthorizedException"Current password is incorrect"propagate401 Unauthorized[!result.Succeeded (other failure)]throw ConflictExceptionpropagate409 Conflict[result.Succeeded]UpdatePasswordPayload(  user.Id, DateTime.UtcNow)UpdatePasswordPayload200 OK{message: "Password updatedsuccessfully.",payload: {...}}Update Profile FlowPATCH /api/auth/profile{firstName, lastName,dateOfBirth}(requires Bearer access token)Send(UpdateProfileCommand(  GetAuthenticatedUserId(),  firstName, lastName,dateOfBirth))ValidationBehavior runs UpdateProfileValidator(same name/DateOfBirth rules as registration)alt[Validation fails]throwsFluentValidation.ValidationException400 Bad Request[Validation succeeds]Handle(command)FindByIdAsync(userAccountId)FindByIdAsync(userAccountId)ApplicationUser or nullApplicationUser or nullalt[Account not found]throw NotFoundExceptionpropagate404 Not Found[Account found]user.FirstName = ...user.LastName = ...user.DateOfBirth = ...First/last name and date of birth aren'ttracked by any Identity store interface -mutated directly on the ApplicationUser,then saved via UserManager.UpdateAsync.UpdateAsync(user)UpdateAsync(user)UPDATE dbo.UserAccountSET FirstName = @FirstName,    LastName = @LastName,    DateOfBirth = @DateOfBirth,...IdentityResultIdentityResultalt[!result.Succeeded]throw ConflictExceptionpropagate409 Conflict[result.Succeeded]UpdateProfilePayload(  user.Id, firstName,  lastName, dateOfBirth)UpdateProfilePayload200 OK{message: "Profile updatedsuccessfully.",payload: {...}}Delete Account FlowDELETE /api/auth/account(requires Bearer access token)Send(DeleteAccountCommand(  GetAuthenticatedUserId()))Handle(command)FindByIdAsync(userAccountId)FindByIdAsync(userAccountId)ApplicationUser or nullApplicationUser or nullalt[Account not found]throw NotFoundExceptionpropagate404 Not Found[Account found]DeleteAsync(user)DeleteAsync(user)DELETE FROM dbo.UserAccountWHERE UserAccountID = @IdUserCredential/UserVerification/UserAvatarcascade on delete. Posts, comments, photos,and follows use ON DELETE NO ACTION - if anyreference this account, the DELETE throws aforeign key violation (SQL error 547).alt[Foreign key violation (dependent records exist)]IdentityResult.Failed(AccountHasDependentRecords)[Delete succeeds]IdentityResult.SuccessIdentityResultalt[Account has dependent records]throw ConflictException"Account cannot be deletedwhile it stillhas associated posts,comments, photos,or follows."propagate409 Conflict[!result.Succeeded (other failure)]throw ConflictExceptionpropagate409 Conflict[result.Succeeded](void)(void)200 OK{message: "Account deletedsuccessfully."}Error Handling (Global Exception Filter)API.Core's GlobalExceptionFilter catches:- FluentValidation.ValidationException -> 400 Bad Request\n {message, errors} (grouped by property)- ConflictException -> 409 Conflict- NotFoundException -> 404 Not Found- UnauthorizedException -> 401 Unauthorized- ForbiddenException -> 403 Forbidden- Domain.Exceptions.ValidationException -> 400 Bad Request- Microsoft.Data.SqlClient.SqlException -> 503 Service Unavailable- All others -> 500 Internal Server Error \ No newline at end of file diff --git a/docs/website/diagrams-out/database-schema.svg b/docs/website/diagrams-out/database-schema.svg index d5acb9ae..e2105a63 100644 --- a/docs/website/diagrams-out/database-schema.svg +++ b/docs/website/diagrams-out/database-schema.svg @@ -1 +1 @@ -Database SchemaDatabase SchemaUser & AuthLocationBreweryBeer (*)UserAccountUserAccountID: UNIQUEIDENTIFIER «PK»Username: VARCHAR(64) «UNIQUE»FirstName: NVARCHAR(128)LastName: NVARCHAR(128)Email: VARCHAR(128) «UNIQUE»CreatedAt: DATETIMEUpdatedAt: DATETIMEDateOfBirth: DATEUserCredentialUserCredentialID: UNIQUEIDENTIFIER «PK»UserAccountID: UNIQUEIDENTIFIER «FK»CreatedAt: DATETIMEExpiry: DATETIMEHash: NVARCHAR(256)IsRevoked: BITRevokedAt: DATETIMEUserVerificationUserVerificationID: UNIQUEIDENTIFIER «PK»UserAccountID: UNIQUEIDENTIFIER «FK, UNIQUE»VerificationDateTime: DATETIMEUserAvatar (*)UserAvatarID: UNIQUEIDENTIFIER «PK»UserAccountID: UNIQUEIDENTIFIER «FK, UNIQUE»PhotoID: UNIQUEIDENTIFIER «FK»UserFollow (*)UserFollowID: UNIQUEIDENTIFIER «PK»UserAccountID: UNIQUEIDENTIFIER «FK»FollowingID: UNIQUEIDENTIFIER «FK»CreatedAt: DATETIMEPhoto (*)PhotoID: UNIQUEIDENTIFIER «PK»Hyperlink: NVARCHAR(256)UploadedByID: UNIQUEIDENTIFIER «FK»UploadedAt: DATETIMECountryCountryID: UNIQUEIDENTIFIER «PK»CountryName: NVARCHAR(100)ISO3166_1: CHAR(2) «UNIQUE»StateProvinceStateProvinceID: UNIQUEIDENTIFIER «PK»StateProvinceName: NVARCHAR(100)ISO3166_2: CHAR(6) «UNIQUE»CountryID: UNIQUEIDENTIFIER «FK»CityCityID: UNIQUEIDENTIFIER «PK»CityName: NVARCHAR(100)StateProvinceID: UNIQUEIDENTIFIER «FK»BreweryPostBreweryPostID: UNIQUEIDENTIFIER «PK»BreweryName: NVARCHAR(256)PostedByID: UNIQUEIDENTIFIER «FK»Description: NVARCHAR(512)CreatedAt: DATETIMEUpdatedAt: DATETIMEBreweryPostLocationBreweryPostLocationID: UNIQUEIDENTIFIER «PK»BreweryPostID: UNIQUEIDENTIFIER «FK, UNIQUE»AddressLine1: NVARCHAR(256)AddressLine2: NVARCHAR(256)PostalCode: NVARCHAR(20)CityID: UNIQUEIDENTIFIER «FK»Coordinates: GEOGRAPHYBreweryPostPhoto (*)BreweryPostPhotoID: UNIQUEIDENTIFIER «PK»BreweryPostID: UNIQUEIDENTIFIER «FK»PhotoID: UNIQUEIDENTIFIER «FK»LinkedAt: DATETIMEBeerStyle (*)BeerStyleID: UNIQUEIDENTIFIER «PK»StyleName: NVARCHAR(100) «UNIQUE»Description: NVARCHAR(MAX)BeerPost (*)BeerPostID: UNIQUEIDENTIFIER «PK»Name: NVARCHAR(100)Description: NVARCHAR(MAX)ABV: DECIMAL(4,2)IBU: INTPostedByID: UNIQUEIDENTIFIER «FK»BeerStyleID: UNIQUEIDENTIFIER «FK»BrewedByID: UNIQUEIDENTIFIER «FK»CreatedAt: DATETIMEUpdatedAt: DATETIMEBeerPostPhoto (*)BeerPostPhotoID: UNIQUEIDENTIFIER «PK»BeerPostID: UNIQUEIDENTIFIER «FK»PhotoID: UNIQUEIDENTIFIER «FK»LinkedAt: DATETIMEBeerPostComment (*)BeerPostCommentID: UNIQUEIDENTIFIER «PK»Comment: NVARCHAR(250)BeerPostID: UNIQUEIDENTIFIER «FK»CommentedByID: UNIQUEIDENTIFIER «FK»Rating: INTCreatedAt: DATETIMEUpdatedAt: DATETIMEBacked by Features.Auth/Features.UserManagement'sDapper repositories (AuthRepository,UserAccountRepository) - registration, credentialrotation, and verification each issue their owninline SQL, no stored proceduresBacked by Features.Breweries' BreweryRepository(Dapper); create/update/delete run inline SQLinside a transaction, with optimistic concurrencyvia RowVersionhashashasrefers tofollows (UserAccountID)followed by (FollowingID)uploadedhashaspostedhaslocated inhaslinked topostedcategorizesbrewedhaslinked tohascommentedEvery table also has a RowVersion ROWVERSION column (optimistic concurrency), omitted below for brevity.Tables marked (*) have no repository/feature slice yet. \ No newline at end of file +Database SchemaDatabase SchemaUser & AuthLocationBreweryBeer (*)UserAccountUserAccountID: UNIQUEIDENTIFIER «PK»Username: VARCHAR(64) «UNIQUE»FirstName: NVARCHAR(128)LastName: NVARCHAR(128)Email: VARCHAR(128) «UNIQUE»CreatedAt: DATETIMEUpdatedAt: DATETIMEDateOfBirth: DATEUserCredentialUserCredentialID: UNIQUEIDENTIFIER «PK»UserAccountID: UNIQUEIDENTIFIER «FK»CreatedAt: DATETIMEExpiry: DATETIMEHash: NVARCHAR(256)IsRevoked: BITRevokedAt: DATETIMEUserVerificationUserVerificationID: UNIQUEIDENTIFIER «PK»UserAccountID: UNIQUEIDENTIFIER «FK, UNIQUE»VerificationDateTime: DATETIMEUserAvatar (*)UserAvatarID: UNIQUEIDENTIFIER «PK»UserAccountID: UNIQUEIDENTIFIER «FK, UNIQUE»PhotoID: UNIQUEIDENTIFIER «FK»UserFollow (*)UserFollowID: UNIQUEIDENTIFIER «PK»UserAccountID: UNIQUEIDENTIFIER «FK»FollowingID: UNIQUEIDENTIFIER «FK»CreatedAt: DATETIMEPhoto (*)PhotoID: UNIQUEIDENTIFIER «PK»Hyperlink: NVARCHAR(256)UploadedByID: UNIQUEIDENTIFIER «FK»UploadedAt: DATETIMECountryCountryID: UNIQUEIDENTIFIER «PK»CountryName: NVARCHAR(100)ISO3166_1: CHAR(2) «UNIQUE»StateProvinceStateProvinceID: UNIQUEIDENTIFIER «PK»StateProvinceName: NVARCHAR(100)ISO3166_2: CHAR(6) «UNIQUE»CountryID: UNIQUEIDENTIFIER «FK»CityCityID: UNIQUEIDENTIFIER «PK»CityName: NVARCHAR(100)StateProvinceID: UNIQUEIDENTIFIER «FK»BreweryPostBreweryPostID: UNIQUEIDENTIFIER «PK»BreweryName: NVARCHAR(256)PostedByID: UNIQUEIDENTIFIER «FK»Description: NVARCHAR(512)CreatedAt: DATETIMEUpdatedAt: DATETIMEBreweryPostLocationBreweryPostLocationID: UNIQUEIDENTIFIER «PK»BreweryPostID: UNIQUEIDENTIFIER «FK, UNIQUE»AddressLine1: NVARCHAR(256)AddressLine2: NVARCHAR(256)PostalCode: NVARCHAR(20)CityID: UNIQUEIDENTIFIER «FK»Coordinates: GEOGRAPHYBreweryPostPhoto (*)BreweryPostPhotoID: UNIQUEIDENTIFIER «PK»BreweryPostID: UNIQUEIDENTIFIER «FK»PhotoID: UNIQUEIDENTIFIER «FK»LinkedAt: DATETIMEBeerStyle (*)BeerStyleID: UNIQUEIDENTIFIER «PK»StyleName: NVARCHAR(100) «UNIQUE»Description: NVARCHAR(MAX)BeerPost (*)BeerPostID: UNIQUEIDENTIFIER «PK»Name: NVARCHAR(100)Description: NVARCHAR(MAX)ABV: DECIMAL(4,2)IBU: INTPostedByID: UNIQUEIDENTIFIER «FK»BeerStyleID: UNIQUEIDENTIFIER «FK»BrewedByID: UNIQUEIDENTIFIER «FK»CreatedAt: DATETIMEUpdatedAt: DATETIMEBeerPostPhoto (*)BeerPostPhotoID: UNIQUEIDENTIFIER «PK»BeerPostID: UNIQUEIDENTIFIER «FK»PhotoID: UNIQUEIDENTIFIER «FK»LinkedAt: DATETIMEBeerPostComment (*)BeerPostCommentID: UNIQUEIDENTIFIER «PK»Comment: NVARCHAR(250)BeerPostID: UNIQUEIDENTIFIER «FK»CommentedByID: UNIQUEIDENTIFIER «FK»Rating: INTCreatedAt: DATETIMEUpdatedAt: DATETIMEAuth reads/writes (registration, login, credentialrotation, verification) go through ASP.NET CoreIdentity's UserManager<ApplicationUser>, backed byFeatures.Auth's DapperUserStore - inline SQL, nostored procedures. Profile data still goes throughFeatures.UserManagement's UserAccountRepository.Backed by Features.Breweries' BreweryRepository(Dapper); create/update/delete run inline SQLinside a transaction, with optimistic concurrencyvia RowVersionhashashasrefers tofollows (UserAccountID)followed by (FollowingID)uploadedhashaspostedhaslocated inhaslinked topostedcategorizesbrewedhaslinked tohascommentedEvery table also has a RowVersion ROWVERSION column (optimistic concurrency), omitted below for brevity.Tables marked (*) have no repository/feature slice yet. \ No newline at end of file diff --git a/docs/website/diagrams-out/deployment.svg b/docs/website/diagrams-out/deployment.svg index 4003a94d..fa36fab5 100644 --- a/docs/website/diagrams-out/deployment.svg +++ b/docs/website/diagrams-out/deployment.svg @@ -1 +1 @@ -Docker Deployment ArchitectureDocker Deployment ArchitectureDevelopment Environment(docker-compose.dev.yaml)SQL Server(mcr.microsoft.com/mssql/server:2022-latest)BiergartenDatabaseAPI Container(API.Core)Migrations(run-once)Seed(run-once)Test Environment(docker-compose.test.yaml)SQL Server(isolated instance)BiergartenTest DatabaseMigrations(test)Seed(test)API.Specs(Integration Tests)unit.tests(Features.*.Tests, one container)test-results/(mounted volume)Environment:- ACCEPT_EULA=Y- SA_PASSWORD=***- MSSQL_PID=Developer Volumes:- biergarten-dev-data1433ASP.NET Core 108080:8080 (HTTP)8081:8081 (HTTPS)Environment:- ASPNETCORE_ENVIRONMENT=Development- DB_SERVER=sql-server- DB_NAME=Biergarten- DB_USER/PASSWORD- JWT_SECRET- SMTP_* (10+ variables) Health Check:/health endpointDatabase.MigrationsRuns: DbUp migrationsEnvironment:- CLEAR_DATABASE=falseDepends on: sql-serverDatabase.SeedCreates:- 100 test users- Location data (US/CA/MX)- test.user accountDepends on: migrationsFresh instance each runCLEAR_DATABASE=true Volumes:- biergarten-test-data(ephemeral)1434Database.MigrationsDatabase.SeedMinimal seed:- test.user only- Essential dataReqnroll + xUnitTests:- Registration flow- Login flow- Validation rules- 404 handling Uses: TestApiFactoryMocks: Email servicesxUnit + MoqBuilds Dockerfile.tests, whichrestores/builds against Core.slnxthen runs `dotnet test` for everyFeatures/*.Tests project in turn:- Features.Auth.Tests- Features.Breweries.Tests- Features.UserManagement.Tests- Features.Emails.Tests Uses: Moq to mock each handler'srepository/service dependenciesNo real database neededapi-specs/results.trxFeatures.Auth.Tests.trxFeatures.Breweries.Tests.trxFeatures.UserManagement.Tests.trxFeatures.Emails.Tests.trxTRX formatReadable by:- Visual Studio- Azure DevOps- GitHub ActionsDeveloperDocker HostDev Network (bridge: biergarten-dev)Internal DNS:- sql-server (resolves to SQL container)- api (resolves to API container)Test Network (bridge: biergarten-test)All test components isolatedStartup Order:1. SQL Server (health check)2. Migrations (run-once)3. Seed (run-once)4. API (long-running)Test Execution:All tests run in parallelResults aggregatedProduction Deployment (not shown): Would include:• Azure SQL Database / AWS RDS• Azure Container Apps / ECS• Azure Key Vault for secrets• Application Insights / CloudWatch• Load balancer• HTTPS termination• CDN for static assetsdocker compose uphttp://localhost:80801. Run migrationsdepends_on2. Seed data3. Connect & servedepends_ondepends_on1. Migratedepends_on2. Seed3. Integration testdepends_on for startupordering only (Moq mocks,no real connection)depends_ondepends_ondepends_onExport TRXExport TRX (one .trx per slice) \ No newline at end of file +Docker Deployment ArchitectureDocker Deployment ArchitectureDevelopment Environment(docker-compose.dev.yaml)SQL Server(mcr.microsoft.com/mssql/server:2022-latest)BiergartenDatabaseAPI Container(API.Core)Migrations(run-once)Seed(run-once)Mailpit(axllent/mailpit)Test Environment(docker-compose.test.yaml)SQL Server(isolated instance)BiergartenTest DatabaseMigrations(test)Seed(test)API.Specs(Integration Tests)unit.tests(Features.*.Tests, one container)Production Environment(docker-compose.prod.yaml)SQL Server(mcr.microsoft.com/mssql/server:2022-latest)Migrations(run-once)API Container(API.Core)Database-only Environment(docker-compose.db.yaml)SQL ServerMinimal Environment(docker-compose.min.yaml)SQL ServerMailpittest-results/(mounted volume)Environment:- ACCEPT_EULA=Y- SA_PASSWORD=***- MSSQL_PID=Developer Volumes:- biergarten-dev-data1433ASP.NET Core 108080:8080 (HTTP)8081:8081 (HTTPS)Environment:- ASPNETCORE_ENVIRONMENT=Development- DB_SERVER=sql-server- DB_NAME=Biergarten- DB_USER/PASSWORD- JWT_SECRET- SMTP_* (10+ variables) Health Check:/health endpointDatabase.MigrationsRuns: DbUp migrationsEnvironment:- CLEAR_DATABASE=falseDepends on: sql-serverDatabase.SeedCreates:- 100 test users- Location data (US/CA/MX)- test.user accountDepends on: migrations8025 (Web UI)1025 (SMTP)Local SMTP catcher for dev email testing.Environment:- MP_SMTP_AUTH_ACCEPT_ANY=1- MP_SMTP_AUTH_ALLOW_INSECURE=1Fresh instance each runCLEAR_DATABASE=true Volumes:- biergarten-test-data(ephemeral)1434Database.MigrationsDatabase.SeedMinimal seed:- test.user only- Essential dataReqnroll + xUnitTests:- Registration flow- Login flow- Validation rules- 404 handling Uses: TestApiFactoryMocks: Email servicesxUnit + MoqBuilds Dockerfile.tests, whichrestores/builds against Core.slnxthen runs `dotnet test` for everyFeatures/*.Tests project in turn:- Features.Auth.Tests- Features.Breweries.Tests- Features.UserManagement.Tests- Features.Emails.Tests Uses: Moq to mock each handler'srepository/service dependenciesNo real database neededProd Network (bridge: prodnet)Isolated from dev/test networks.Volumes: sqlserverdata-prod,nuget-cache-prodBiergartenDatabaseEnvironment:- ACCEPT_EULA=Y- SA_PASSWORD=***- MSSQL_PID=Express Volumes:- sqlserverdata-prodNo host port published.Database.MigrationsDepends on: sql-server (healthy)No seed step in production.ASP.NET Core 108080:8080 (HTTP)8081:8081 (HTTPS)Environment:- ASPNETCORE_ENVIRONMENT=Production- DB_SERVER/NAME/USER/PASSWORD- ACCESS/REFRESH/CONFIRMATION_TOKEN_SECRET- WEBSITE_BASE_URL Depends on: sql-server (healthy) -does not wait on migrations/seed.Same devnet volumes/images asdocker-compose.dev.yaml, minusapi.core and mailpit - for workingagainst the database withoutrunning the API.BiergartenDatabaseMigrations(run-once)Seed(run-once)Database + mailpit only, no APIcontainer - for frontend developmentagainst an API.Core process runoutside Docker.BiergartenDatabaseMigrations(run-once)8025 / 1025Seed(run-once)api-specs/results.trxFeatures.Auth.Tests.trxFeatures.Breweries.Tests.trxFeatures.UserManagement.Tests.trxFeatures.Emails.Tests.trxTRX formatReadable by:- Visual Studio- Azure DevOps- GitHub ActionsDeveloperDocker HostDev Network (bridge: biergarten-dev)Internal DNS:- sql-server (resolves to SQL container)- api (resolves to API container)Test Network (bridge: biergarten-test)All test components isolatedStartup Order:1. SQL Server (health check)2. Migrations (run-once)3. Seed (run-once)4. API (long-running)Test Execution:All tests run in parallelResults aggregated1. Run migrationsdepends_on2. Connect & servedepends_on1. Run migrationsdepends_on2. Seed datadepends_on1. Run migrationsdepends_on2. Seed datadepends_ondocker compose uphttp://localhost:80801. Run migrationsdepends_on2. Seed data3. Connect & serve4. SMTP_HOST/PORTdepends_ondepends_on1. Migratedepends_on2. Seed3. Integration testdepends_on for startupordering only (Moq mocks,no real connection)depends_ondepends_ondepends_onExport TRXExport TRX (one .trx per slice) \ No newline at end of file diff --git a/docs/website/diagrams-src/authentication-flow.puml b/docs/website/diagrams-src/authentication-flow.puml index 72c8c25f..b5c97f47 100644 --- a/docs/website/diagrams-src/authentication-flow.puml +++ b/docs/website/diagrams-src/authentication-flow.puml @@ -13,20 +13,38 @@ participant "MediatR pipeline\n(ValidationBehavior)" as Mediator box "Handler Layer" #LightBlue participant "RegisterUserHandler" as RegHandler participant "LoginHandler" as LoginHandler +participant "RefreshTokenHandler" as RefreshHandler +participant "ConfirmUserHandler" as ConfirmHandler +participant "ResendConfirmationEmailHandler" as ResendHandler +participant "UpdateUsernameHandler" as UpdateUsernameHandler +participant "UpdateEmailHandler" as UpdateEmailHandler +participant "UpdatePasswordHandler" as UpdatePasswordHandler +participant "UpdateProfileHandler" as UpdateProfileHandler +participant "DeleteAccountHandler" as DeleteAccountHandler +participant "UserManager" as UserMgr participant "TokenService" as TokenSvc end box box "Infrastructure Layer" #LightGreen -participant "Argon2\nInfrastructure" as Argon2 +participant "Argon2PasswordHasher" as Argon2 participant "JWT\nInfrastructure" as JWT participant "Email\nProvider" as SMTP participant "Template\nProvider" as Template end box box "Repository / Cross-slice" #LightYellow -participant "AuthRepository" as AuthRepo +participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore participant "EmailDispatcher\n(Features.Emails)" as EmailDispatcher end box database "SQL Server" as DB +note over UserMgr + ASP.NET Core Identity's UserManager + replaces the deleted AuthRepository: handlers + no longer issue SQL or call Argon2 directly, + they delegate to UserManager, which in turn + calls DapperUserStore (persistence) and + Argon2PasswordHasher (IPasswordHasher). +end note + == Registration Flow == User -> API: POST /api/auth/register\n{username, firstName, lastName,\nemail, dateOfBirth, password} @@ -51,71 +69,83 @@ else Validation succeeds Mediator -> RegHandler: Handle(command) activate RegHandler - RegHandler -> AuthRepo: GetUserByUsernameAsync(username) - activate AuthRepo - AuthRepo -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username - activate DB - DB --> AuthRepo: null (user doesn't exist) - deactivate DB - deactivate AuthRepo + RegHandler -> RegHandler: user = new UserAccount{...}\n.ToApplicationUser() - RegHandler -> AuthRepo: GetUserByEmailAsync(email) - activate AuthRepo - AuthRepo -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Email = @Email - activate DB - DB --> AuthRepo: null (email doesn't exist) - deactivate DB - deactivate AuthRepo + RegHandler -> UserMgr: CreateAsync(user, password) + activate UserMgr - alt Username or email already exists - RegHandler -> Mediator: throw ConflictException + UserMgr -> Argon2: HashPassword(user, password) + activate Argon2 + note right of Argon2 + Delegates to IPasswordInfrastructure.Hash: + Argon2id parameters: + - Salt: 16 bytes (128-bit) + - Memory: 64MB + - Iterations: 4 + - Parallelism: CPU count + - Hash output: 32 bytes + end note + Argon2 --> UserMgr: "base64(salt):base64(hash)" + deactivate Argon2 + + UserMgr -> UserStore: SetPasswordHashAsync(user, hash) + note right of UserStore + user.Id is still empty at this point - + only sets the in-memory hash. CreateAsync + writes it to dbo.UserCredential itself. + end note + + UserMgr -> UserStore: FindByNameAsync(username) + activate UserStore + UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username + activate DB + DB --> UserStore: null (user doesn't exist) + deactivate DB + deactivate UserStore + + UserMgr -> UserStore: FindByEmailAsync(email) + activate UserStore + UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Email = @Email + activate DB + DB --> UserStore: null (email doesn't exist) + deactivate DB + deactivate UserStore + note right of UserMgr + Default UserManager validators check + uniqueness before CreateAsync is called; + DapperUserStore.CreateAsync also guards + against a genuine race via a caught + duplicate-key SqlException (2601/2627). + end note + + UserMgr -> UserStore: CreateAsync(user) + activate UserStore + + UserStore -> DB: BEGIN TRANSACTION + activate DB + UserStore -> DB: INSERT INTO dbo.UserAccount\n(Username, FirstName, LastName,\nDateOfBirth, Email)\nOUTPUT INSERTED.UserAccountID + DB --> UserStore: UserAccountId (GUID) + UserStore -> DB: INSERT INTO dbo.UserCredential\n(UserAccountId, Hash) + UserStore -> DB: COMMIT TRANSACTION + deactivate DB + note right of UserStore + Both statements run via Dapper's + ExecuteScalarAsync/ExecuteAsync against + a single DbTransaction, not a stored + procedure - no manual parameter binding + end note + + UserStore --> UserMgr: IdentityResult.Success\n(user.Id set) + deactivate UserStore + + UserMgr --> RegHandler: IdentityResult + deactivate UserMgr + + alt !result.Succeeded + RegHandler -> Mediator: throw ConflictException\n(DuplicateUserName/DuplicateEmail codes ->\n"Username or email already exists";\notherwise joined error descriptions) Mediator -> API: propagate API -> User: 409 Conflict\n"Username or email already exists" - else Username and email are free - - RegHandler -> Argon2: Hash(password) - activate Argon2 - note right of Argon2 - Argon2id parameters: - - Salt: 16 bytes (128-bit) - - Memory: 64MB - - Iterations: 4 - - Parallelism: CPU count - - Hash output: 32 bytes - end note - Argon2 -> Argon2: Generate random salt\n(16 bytes) - Argon2 -> Argon2: Hash password with\nArgon2id algorithm - Argon2 --> RegHandler: "base64(salt):base64(hash)" - deactivate Argon2 - - RegHandler -> AuthRepo: RegisterUserAsync(userAccount) - activate AuthRepo - - AuthRepo -> DB: BEGIN TRANSACTION - activate DB - AuthRepo -> DB: INSERT INTO dbo.UserAccount\n(Username, FirstName, LastName,\nDateOfBirth, Email)\nOUTPUT INSERTED.UserAccountID - DB --> AuthRepo: UserAccountId (GUID) - AuthRepo -> DB: INSERT INTO dbo.UserCredential\n(UserAccountId, Hash) - AuthRepo -> DB: COMMIT TRANSACTION - deactivate DB - note right of AuthRepo - All three statements run via Dapper's - ExecuteScalarAsync/ExecuteAsync against - a single DbTransaction, not a stored - procedure - no manual parameter binding - end note - - AuthRepo -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @UserAccountId - activate DB - note right of DB - Round trip to load the row the - INSERT just created - end note - DB --> AuthRepo: UserAccount row - deactivate DB - - AuthRepo --> RegHandler: UserAccount entity - deactivate AuthRepo + else result.Succeeded RegHandler -> TokenSvc: GenerateAccessToken(user) activate TokenSvc @@ -242,13 +272,19 @@ else Validation succeeds Mediator -> LoginHandler: Handle(command) activate LoginHandler - LoginHandler -> AuthRepo: GetUserByUsernameAsync(username) - activate AuthRepo - AuthRepo -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username + LoginHandler -> UserMgr: FindByNameAsync(username) + activate UserMgr + UserMgr -> UserStore: FindByNameAsync(username) + activate UserStore + UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username activate DB - DB --> AuthRepo: UserAccount row or null + DB --> UserStore: UserAccount row or null deactivate DB - deactivate AuthRepo + UserStore -> UserStore: HydrateAsync\n(load active UserCredential.Hash,\nUserVerification -> EmailConfirmed) + UserStore --> UserMgr: ApplicationUser or null + deactivate UserStore + UserMgr --> LoginHandler: ApplicationUser or null + deactivate UserMgr alt User not found LoginHandler -> Mediator: throw UnauthorizedException\n"Invalid username or password." @@ -256,41 +292,34 @@ else Validation succeeds API -> User: 401 Unauthorized else User found - LoginHandler -> AuthRepo: GetActiveCredentialByUserAccountIdAsync(userId) - activate AuthRepo - AuthRepo -> DB: SELECT UserCredentialId, UserAccountId,\nHash, CreatedAt, RowVersion\nFROM dbo.UserCredential\nWHERE UserAccountId = @UserAccountId\n AND IsRevoked = 0 - activate DB - DB --> AuthRepo: UserCredential row or null - deactivate DB - deactivate AuthRepo + LoginHandler -> UserMgr: CheckPasswordAsync(user, password) + activate UserMgr + note right of UserMgr + HasPasswordAsync short-circuits to false + (no active UserCredential was hydrated) + without calling the hasher; otherwise + delegates to Argon2PasswordHasher. + end note + UserMgr -> Argon2: VerifyHashedPassword(\n user, user.PasswordHash, password) + activate Argon2 + note right of Argon2 + Delegates to IPasswordInfrastructure.Verify: + 1. Split stored hash: "salt:hash" + 2. Hash provided password with the same salt + 3. Constant-time comparison\n (CryptographicOperations.FixedTimeEquals) + end note + Argon2 --> UserMgr: Success/Failed + deactivate Argon2 + UserMgr --> LoginHandler: true/false + deactivate UserMgr - alt No active credential + alt Password invalid LoginHandler -> Mediator: throw UnauthorizedException Mediator -> API: propagate API -> User: 401 Unauthorized - else Active credential found + else Password valid - LoginHandler -> Argon2: Verify(password, storedHash) - activate Argon2 - note right of Argon2 - 1. Split stored hash: "salt:hash" - 2. Extract salt - 3. Hash provided password\n with same salt - 4. Constant-time comparison\n (CryptographicOperations.FixedTimeEquals) - end note - Argon2 -> Argon2: Parse salt from stored hash - Argon2 -> Argon2: Hash provided password\nwith extracted salt - Argon2 -> Argon2: FixedTimeEquals(\n computed, stored) - Argon2 --> LoginHandler: true/false - deactivate Argon2 - - alt Password invalid - LoginHandler -> Mediator: throw UnauthorizedException - Mediator -> API: propagate - API -> User: 401 Unauthorized - else Password valid - - LoginHandler -> TokenSvc: GenerateAccessToken(user) + LoginHandler -> TokenSvc: GenerateAccessToken(user) activate TokenSvc TokenSvc -> JWT: GenerateJwt(...) activate JWT @@ -317,6 +346,567 @@ else Validation succeeds end end end +deactivate API + +== Refresh Token Flow == + +User -> API: POST /api/auth/refresh\nHeader: X-Refresh-Token: +activate API + +API -> Mediator: Send(RefreshTokenCommand(refreshToken)) +activate Mediator + +note right of Mediator + ValidationBehavior runs RefreshTokenValidator: + refreshToken must be non-empty. Anonymous + endpoint - no Bearer token required. +end note + +alt Validation fails + Mediator -> API: throws FluentValidation.ValidationException + API -> User: 400 Bad Request\n{message: "Validation failed", errors: {...}} +else Validation succeeds + + Mediator -> RefreshHandler: Handle(command) + activate RefreshHandler + + RefreshHandler -> TokenSvc: RefreshTokenAsync(refreshToken) + activate TokenSvc + + TokenSvc -> JWT: ValidateRefreshTokenAsync(refreshToken) + activate JWT + note right of JWT + Validates signature/expiry against the + refresh token secret, extracts userId/ + username claims. + end note + JWT --> TokenSvc: ValidatedToken or throws + deactivate JWT + + alt Token invalid or expired + TokenSvc -> RefreshHandler: throw UnauthorizedException + RefreshHandler -> Mediator: propagate + Mediator -> API: propagate + API -> User: 401 Unauthorized + else Token valid + + TokenSvc -> UserMgr: FindByIdAsync(validated.UserId) + activate UserMgr + UserMgr -> UserStore: FindByIdAsync(userId) + activate UserStore + UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @Id + activate DB + DB --> UserStore: row or null + deactivate DB + UserStore --> UserMgr: ApplicationUser or null + deactivate UserStore + UserMgr --> TokenSvc: ApplicationUser or null + deactivate UserMgr + + alt User not found + TokenSvc -> RefreshHandler: throw UnauthorizedException\n"User account not found" + RefreshHandler -> Mediator: propagate + Mediator -> API: propagate + API -> User: 401 Unauthorized + else User found + TokenSvc -> JWT: GenerateAccessToken(user)\nGenerateRefreshToken(user) + JWT --> TokenSvc: new access + refresh tokens + TokenSvc --> RefreshHandler: RefreshTokenResult + deactivate TokenSvc + + RefreshHandler --> Mediator: LoginPayload(\n userId, username,\n refreshToken, accessToken) + deactivate RefreshHandler + Mediator --> API: LoginPayload + deactivate Mediator + + API -> User: 200 OK\n{message: "Token refreshed successfully.",\npayload: {...}} + end + end +end +deactivate API + +== Confirm User Flow == + +User -> API: POST /api/auth/confirm?token=\n(requires Bearer access token) +activate API + +API -> Mediator: Send(ConfirmUserCommand(token)) +activate Mediator + +Mediator -> ConfirmHandler: Handle(command) +activate ConfirmHandler + +ConfirmHandler -> TokenSvc: ValidateConfirmationTokenAsync(token) +activate TokenSvc +TokenSvc -> JWT: Validate against confirmation\ntoken secret, extract userId +activate JWT +JWT --> TokenSvc: ValidatedToken or throws +deactivate JWT +TokenSvc --> ConfirmHandler: ValidatedToken +deactivate TokenSvc + +alt Token invalid or expired + ConfirmHandler -> Mediator: throw UnauthorizedException + Mediator -> API: propagate + API -> User: 401 Unauthorized +else Token valid + + ConfirmHandler -> UserMgr: FindByIdAsync(validatedToken.UserId) + activate UserMgr + UserMgr -> UserStore: FindByIdAsync(userId) + activate UserStore + UserStore -> DB: SELECT ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @Id + activate DB + DB --> UserStore: row or null + deactivate DB + UserStore --> UserMgr: ApplicationUser or null + deactivate UserStore + UserMgr --> ConfirmHandler: ApplicationUser or null + deactivate UserMgr + + alt User not found + ConfirmHandler -> Mediator: throw UnauthorizedException\n"User account not found" + Mediator -> API: propagate + API -> User: 401 Unauthorized + else User found + + ConfirmHandler -> UserStore: SetEmailConfirmedAsync(user, true) + note right of UserStore + IUserEmailStore is injected + directly into the handler (not via UserManager), + setting the confirmed flag in-memory here. + end note + + ConfirmHandler -> UserMgr: UpdateAsync(user) + activate UserMgr + UserMgr -> UserStore: UpdateAsync(user) + activate UserStore + UserStore -> DB: UPDATE dbo.UserAccount SET ... + UserStore -> DB: INSERT INTO dbo.UserVerification\n(UserAccountId, VerificationDateTime) + note right of UserStore + Idempotent: skipped (duplicate-key + exception swallowed) if a verification + row already exists. + end note + UserStore --> UserMgr: IdentityResult.Success + deactivate UserStore + UserMgr --> ConfirmHandler: IdentityResult + deactivate UserMgr + + ConfirmHandler --> Mediator: ConfirmationPayload(\n user.Id, DateTime.UtcNow) + deactivate ConfirmHandler + Mediator --> API: ConfirmationPayload + deactivate Mediator + + API -> User: 200 OK\n{message: "User with ID {id} is confirmed.",\npayload: {...}} + end +end +deactivate API + +== Resend Confirmation Email Flow == + +User -> API: POST /api/auth/confirm/resend?userId=\n(requires Bearer access token) +activate API + +API -> Mediator: Send(ResendConfirmationEmailCommand(userId)) +activate Mediator + +Mediator -> ResendHandler: Handle(command) +activate ResendHandler + +ResendHandler -> UserMgr: FindByIdAsync(userId) +activate UserMgr +UserMgr -> UserStore: FindByIdAsync(userId) +activate UserStore +UserStore -> DB: SELECT ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @Id +activate DB +DB --> UserStore: row or null +deactivate DB +UserStore --> UserMgr: ApplicationUser or null +deactivate UserStore +UserMgr --> ResendHandler: ApplicationUser or null +deactivate UserMgr + +alt User not found + ResendHandler -> ResendHandler: return silently\n(prevents user enumeration) +else User found + + ResendHandler -> UserMgr: IsEmailConfirmedAsync(user) + UserMgr --> ResendHandler: bool + + alt Already confirmed + ResendHandler -> ResendHandler: return silently (no-op) + else Not yet confirmed + + ResendHandler -> TokenSvc: GenerateConfirmationToken(user) + activate TokenSvc + TokenSvc --> ResendHandler: confirmation token + deactivate TokenSvc + + ResendHandler -> Mediator: Send(SendResendConfirmationEmailCommand) + activate Mediator + Mediator -> EmailDispatcher: SendResendConfirmationEmailAsync(\n firstName, email, confirmationToken) + activate EmailDispatcher + EmailDispatcher -> Template: Render email\n(same Razor pipeline as registration) + EmailDispatcher -> SMTP: SendAsync(email, subject, body) + deactivate EmailDispatcher + deactivate Mediator + end +end + +ResendHandler --> Mediator: (void) +deactivate ResendHandler +Mediator --> API: (void) +deactivate Mediator + +API -> User: 200 OK\n{message: "confirmation email has been resent"} +note right of API + Always 200 OK regardless of outcome above - + same user-enumeration precaution as a not-found + user or an already-confirmed account. +end note +deactivate API + +== Update Username Flow == + +User -> API: PATCH /api/auth/username\n{newUsername}\n(requires Bearer access token) +activate API + +API -> Mediator: Send(UpdateUsernameCommand(\n GetAuthenticatedUserId(), newUsername)) +activate Mediator + +note right of Mediator + ValidationBehavior runs UpdateUsernameValidator + (same format rules as registration) +end note + +alt Validation fails + Mediator -> API: throws FluentValidation.ValidationException + API -> User: 400 Bad Request +else Validation succeeds + + Mediator -> UpdateUsernameHandler: Handle(command) + activate UpdateUsernameHandler + + UpdateUsernameHandler -> UserMgr: FindByIdAsync(userAccountId) + activate UserMgr + UserMgr -> UserStore: FindByIdAsync(userAccountId) + activate UserStore + UserStore -> DB: SELECT ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @Id + activate DB + DB --> UserStore: row or null + deactivate DB + UserStore --> UserMgr: ApplicationUser or null + deactivate UserStore + UserMgr --> UpdateUsernameHandler: ApplicationUser or null + deactivate UserMgr + + alt Account not found + UpdateUsernameHandler -> Mediator: throw NotFoundException + Mediator -> API: propagate + API -> User: 404 Not Found + else Account found + + UpdateUsernameHandler -> UserMgr: SetUserNameAsync(user, newUsername) + activate UserMgr + UserMgr -> UserStore: FindByNameAsync(newUsername) + note right of UserMgr + Uniqueness check before the UPDATE. + end note + UserMgr -> UserStore: UpdateAsync(user) + UserStore -> DB: UPDATE dbo.UserAccount\nSET Username = @UserName, ... + UserStore --> UserMgr: IdentityResult + UserMgr --> UpdateUsernameHandler: IdentityResult + deactivate UserMgr + + alt !result.Succeeded + UpdateUsernameHandler -> Mediator: throw ConflictException\n(DuplicateUserName code ->\n"Username already exists") + Mediator -> API: propagate + API -> User: 409 Conflict + else result.Succeeded + UpdateUsernameHandler --> Mediator: UpdateUsernamePayload(\n user.Id, user.UserName) + deactivate UpdateUsernameHandler + Mediator --> API: UpdateUsernamePayload + deactivate Mediator + API -> User: 200 OK\n{message: "Username updated successfully.",\npayload: {...}} + end + end +end +deactivate API + +== Update Email Flow == + +User -> API: PATCH /api/auth/email\n{newEmail}\n(requires Bearer access token) +activate API + +API -> Mediator: Send(UpdateEmailCommand(\n GetAuthenticatedUserId(), newEmail)) +activate Mediator + +note right of Mediator + ValidationBehavior runs UpdateEmailValidator + (same format rules as registration) +end note + +alt Validation fails + Mediator -> API: throws FluentValidation.ValidationException + API -> User: 400 Bad Request +else Validation succeeds + + Mediator -> UpdateEmailHandler: Handle(command) + activate UpdateEmailHandler + + UpdateEmailHandler -> UserMgr: FindByIdAsync(userAccountId) + activate UserMgr + UserMgr -> UserStore: FindByIdAsync(userAccountId) + UserStore -> DB: SELECT ... + UserStore --> UserMgr: ApplicationUser or null + UserMgr --> UpdateEmailHandler: ApplicationUser or null + deactivate UserMgr + + alt Account not found + UpdateEmailHandler -> Mediator: throw NotFoundException + Mediator -> API: propagate + API -> User: 404 Not Found + else Account found + + UpdateEmailHandler -> UserMgr: SetEmailAsync(user, newEmail) + activate UserMgr + UserMgr -> UserStore: FindByEmailAsync(newEmail) + note right of UserMgr + Uniqueness check. Also resets + user.EmailConfirmed = false - + the caller must re-confirm via + POST /api/auth/confirm/resend. + end note + UserMgr -> UserStore: UpdateAsync(user) + UserStore -> DB: UPDATE dbo.UserAccount\nSET Email = @Email, ... + UserStore -> DB: DELETE FROM dbo.UserVerification\nWHERE UserAccountID = @Id + UserStore --> UserMgr: IdentityResult + UserMgr --> UpdateEmailHandler: IdentityResult + deactivate UserMgr + + alt !result.Succeeded + UpdateEmailHandler -> Mediator: throw ConflictException\n(DuplicateEmail code ->\n"Email address already exists") + Mediator -> API: propagate + API -> User: 409 Conflict + else result.Succeeded + UpdateEmailHandler --> Mediator: UpdateEmailPayload(\n user.Id, user.Email,\n user.EmailConfirmed = false) + deactivate UpdateEmailHandler + Mediator --> API: UpdateEmailPayload + deactivate Mediator + API -> User: 200 OK\n{message: "Email updated successfully.",\npayload: {...}} + end + end +end +deactivate API + +== Update Password Flow == + +User -> API: PATCH /api/auth/password\n{currentPassword, newPassword}\n(requires Bearer access token) +activate API + +API -> Mediator: Send(UpdatePasswordCommand(\n GetAuthenticatedUserId(),\n currentPassword, newPassword)) +activate Mediator + +note right of Mediator + ValidationBehavior runs UpdatePasswordValidator + (same password strength rules as registration) +end note + +alt Validation fails + Mediator -> API: throws FluentValidation.ValidationException + API -> User: 400 Bad Request +else Validation succeeds + + Mediator -> UpdatePasswordHandler: Handle(command) + activate UpdatePasswordHandler + + UpdatePasswordHandler -> UserMgr: FindByIdAsync(userAccountId) + activate UserMgr + UserMgr -> UserStore: FindByIdAsync(userAccountId) + UserStore --> UserMgr: ApplicationUser or null + UserMgr --> UpdatePasswordHandler: ApplicationUser or null + deactivate UserMgr + + alt Account not found + UpdatePasswordHandler -> Mediator: throw NotFoundException + Mediator -> API: propagate + API -> User: 404 Not Found + else Account found + + UpdatePasswordHandler -> UserMgr: ChangePasswordAsync(\n user, currentPassword, newPassword) + activate UserMgr + UserMgr -> Argon2: VerifyHashedPassword(\n user, user.PasswordHash, currentPassword) + activate Argon2 + Argon2 --> UserMgr: Success/Failed + deactivate Argon2 + + alt Current password mismatch + UserMgr --> UpdatePasswordHandler: IdentityResult.Failed\n(PasswordMismatch) + else Current password matches + UserMgr -> Argon2: HashPassword(user, newPassword) + activate Argon2 + Argon2 --> UserMgr: new hash + deactivate Argon2 + UserMgr -> UserStore: SetPasswordHashAsync(user, newHash) + activate UserStore + UserStore -> DB: UPDATE dbo.UserCredential\nSET IsRevoked = 1, RevokedAt = GETDATE()\nWHERE UserAccountId = @Id AND IsRevoked = 0 + UserStore -> DB: INSERT INTO dbo.UserCredential\n(UserAccountId, Hash) + note right of UserStore + Revoke-then-insert runs inside one + DbTransaction (credential rotation). + end note + UserStore --> UserMgr: (void) + deactivate UserStore + UserMgr --> UpdatePasswordHandler: IdentityResult.Success + end + deactivate UserMgr + + alt Current password is incorrect + UpdatePasswordHandler -> Mediator: throw UnauthorizedException\n"Current password is incorrect" + Mediator -> API: propagate + API -> User: 401 Unauthorized + else !result.Succeeded (other failure) + UpdatePasswordHandler -> Mediator: throw ConflictException + Mediator -> API: propagate + API -> User: 409 Conflict + else result.Succeeded + UpdatePasswordHandler --> Mediator: UpdatePasswordPayload(\n user.Id, DateTime.UtcNow) + deactivate UpdatePasswordHandler + Mediator --> API: UpdatePasswordPayload + deactivate Mediator + API -> User: 200 OK\n{message: "Password updated successfully.",\npayload: {...}} + end + end +end +deactivate API + +== Update Profile Flow == + +User -> API: PATCH /api/auth/profile\n{firstName, lastName, dateOfBirth}\n(requires Bearer access token) +activate API + +API -> Mediator: Send(UpdateProfileCommand(\n GetAuthenticatedUserId(),\n firstName, lastName, dateOfBirth)) +activate Mediator + +note right of Mediator + ValidationBehavior runs UpdateProfileValidator + (same name/DateOfBirth rules as registration) +end note + +alt Validation fails + Mediator -> API: throws FluentValidation.ValidationException + API -> User: 400 Bad Request +else Validation succeeds + + Mediator -> UpdateProfileHandler: Handle(command) + activate UpdateProfileHandler + + UpdateProfileHandler -> UserMgr: FindByIdAsync(userAccountId) + activate UserMgr + UserMgr -> UserStore: FindByIdAsync(userAccountId) + UserStore --> UserMgr: ApplicationUser or null + UserMgr --> UpdateProfileHandler: ApplicationUser or null + deactivate UserMgr + + alt Account not found + UpdateProfileHandler -> Mediator: throw NotFoundException + Mediator -> API: propagate + API -> User: 404 Not Found + else Account found + + UpdateProfileHandler -> UpdateProfileHandler: user.FirstName = ...\nuser.LastName = ...\nuser.DateOfBirth = ... + note right of UpdateProfileHandler + First/last name and date of birth aren't + tracked by any Identity store interface - + mutated directly on the ApplicationUser, + then saved via UserManager.UpdateAsync. + end note + + UpdateProfileHandler -> UserMgr: UpdateAsync(user) + activate UserMgr + UserMgr -> UserStore: UpdateAsync(user) + UserStore -> DB: UPDATE dbo.UserAccount\nSET FirstName = @FirstName,\n LastName = @LastName,\n DateOfBirth = @DateOfBirth, ... + UserStore --> UserMgr: IdentityResult + UserMgr --> UpdateProfileHandler: IdentityResult + deactivate UserMgr + + alt !result.Succeeded + UpdateProfileHandler -> Mediator: throw ConflictException + Mediator -> API: propagate + API -> User: 409 Conflict + else result.Succeeded + UpdateProfileHandler --> Mediator: UpdateProfilePayload(\n user.Id, firstName,\n lastName, dateOfBirth) + deactivate UpdateProfileHandler + Mediator --> API: UpdateProfilePayload + deactivate Mediator + API -> User: 200 OK\n{message: "Profile updated successfully.",\npayload: {...}} + end + end +end +deactivate API + +== Delete Account Flow == + +User -> API: DELETE /api/auth/account\n(requires Bearer access token) +activate API + +API -> Mediator: Send(DeleteAccountCommand(\n GetAuthenticatedUserId())) +activate Mediator + +Mediator -> DeleteAccountHandler: Handle(command) +activate DeleteAccountHandler + +DeleteAccountHandler -> UserMgr: FindByIdAsync(userAccountId) +activate UserMgr +UserMgr -> UserStore: FindByIdAsync(userAccountId) +UserStore --> UserMgr: ApplicationUser or null +UserMgr --> DeleteAccountHandler: ApplicationUser or null +deactivate UserMgr + +alt Account not found + DeleteAccountHandler -> Mediator: throw NotFoundException + Mediator -> API: propagate + API -> User: 404 Not Found +else Account found + + DeleteAccountHandler -> UserMgr: DeleteAsync(user) + activate UserMgr + UserMgr -> UserStore: DeleteAsync(user) + activate UserStore + UserStore -> DB: DELETE FROM dbo.UserAccount\nWHERE UserAccountID = @Id + note right of UserStore + UserCredential/UserVerification/UserAvatar + cascade on delete. Posts, comments, photos, + and follows use ON DELETE NO ACTION - if any + reference this account, the DELETE throws a + foreign key violation (SQL error 547). + end note + + alt Foreign key violation (dependent records exist) + UserStore --> UserMgr: IdentityResult.Failed\n(AccountHasDependentRecords) + else Delete succeeds + UserStore --> UserMgr: IdentityResult.Success + end + deactivate UserStore + UserMgr --> DeleteAccountHandler: IdentityResult + deactivate UserMgr + + alt Account has dependent records + DeleteAccountHandler -> Mediator: throw ConflictException\n"Account cannot be deleted while it still\nhas associated posts, comments, photos,\nor follows." + Mediator -> API: propagate + API -> User: 409 Conflict + else !result.Succeeded (other failure) + DeleteAccountHandler -> Mediator: throw ConflictException + Mediator -> API: propagate + API -> User: 409 Conflict + else result.Succeeded + DeleteAccountHandler --> Mediator: (void) + deactivate DeleteAccountHandler + Mediator --> API: (void) + deactivate Mediator + API -> User: 200 OK\n{message: "Account deleted successfully."} + end end deactivate API diff --git a/docs/website/diagrams-src/database-schema.puml b/docs/website/diagrams-src/database-schema.puml index d180e533..11524e91 100644 --- a/docs/website/diagrams-src/database-schema.puml +++ b/docs/website/diagrams-src/database-schema.puml @@ -190,11 +190,12 @@ Beer ||--o{ BeerComment : "has" User ||--o{ BeerComment : "commented" note bottom of User - Backed by Features.Auth/Features.UserManagement's - Dapper repositories (AuthRepository, - UserAccountRepository) - registration, credential - rotation, and verification each issue their own - inline SQL, no stored procedures + Auth reads/writes (registration, login, credential + rotation, verification) go through ASP.NET Core + Identity's UserManager, backed by + Features.Auth's DapperUserStore - inline SQL, no + stored procedures. Profile data still goes through + Features.UserManagement's UserAccountRepository. end note note bottom of Brewery diff --git a/docs/website/diagrams-src/deployment.puml b/docs/website/diagrams-src/deployment.puml index 9437cc2d..7b31d115 100644 --- a/docs/website/diagrams-src/deployment.puml +++ b/docs/website/diagrams-src/deployment.puml @@ -67,6 +67,17 @@ package "Development Environment\n(docker-compose.dev.yaml)" #E3F2FD { end note } + node "Mailpit\n(axllent/mailpit)" as DevMailpit { + portin "8025 (Web UI)" as DevMailpitWeb + portin "1025 (SMTP)" as DevMailpitSmtp + note right + Local SMTP catcher for dev email testing. + Environment: + - MP_SMTP_AUTH_ACCEPT_ANY=1 + - MP_SMTP_AUTH_ALLOW_INSECURE=1 + end note + } + } package "Test Environment\n(docker-compose.test.yaml)" #FFF3E0 { @@ -132,6 +143,122 @@ package "Test Environment\n(docker-compose.test.yaml)" #FFF3E0 { } +package "Production Environment\n(docker-compose.prod.yaml)" #FCE4EC { + + node "SQL Server\n(mcr.microsoft.com/mssql/server:2022-latest)" as ProdDB { + database "Biergarten\nDatabase" as ProdDBInner + note right + Environment: + - ACCEPT_EULA=Y + - SA_PASSWORD=*** + - MSSQL_PID=Express + + Volumes: + - sqlserverdata-prod + No host port published. + end note + } + + node "Migrations\n(run-once)" as ProdMig { + component "Database.Migrations" as ProdMig1 + note bottom + Depends on: sql-server (healthy) + No seed step in production. + end note + } + + node "API Container\n(API.Core)" as ProdAPI { + component "ASP.NET Core 10" as ProdAPI1 + portin "8080:8080 (HTTP)" as ProdPort1 + portin "8081:8081 (HTTPS)" as ProdPort2 + note right + Environment: + - ASPNETCORE_ENVIRONMENT=Production + - DB_SERVER/NAME/USER/PASSWORD + - ACCESS/REFRESH/CONFIRMATION_TOKEN_SECRET + - WEBSITE_BASE_URL + + Depends on: sql-server (healthy) - + does not wait on migrations/seed. + end note + } + + ProdMig --> ProdDB : 1. Run migrations + ProdAPI --> ProdDB : 2. Connect & serve + ProdMig .up.> ProdDB : depends_on + ProdAPI .up.> ProdDB : depends_on + + note bottom of ProdDB + Prod Network (bridge: prodnet) + Isolated from dev/test networks. + Volumes: sqlserverdata-prod, + nuget-cache-prod + end note +} + +package "Database-only Environment\n(docker-compose.db.yaml)" #EDE7F6 { + + node "SQL Server" as DbOnlyDB { + database "Biergarten\nDatabase" as DbOnlyDBInner { + portin "1434" + } + } + + node "Migrations\n(run-once)" as DbOnlyMig { + component "Database.Migrations" + } + + node "Seed\n(run-once)" as DbOnlySeed { + component "Database.Seed" + } + + note bottom of DbOnlyDB + Same devnet volumes/images as + docker-compose.dev.yaml, minus + api.core and mailpit - for working + against the database without + running the API. + end note + + DbOnlyMig --> DbOnlyDB : 1. Run migrations + DbOnlySeed --> DbOnlyDB : 2. Seed data + DbOnlyMig .up.> DbOnlyDB : depends_on + DbOnlySeed .up.> DbOnlyMig : depends_on +} + +package "Minimal Environment\n(docker-compose.min.yaml)" #FFF9C4 { + + node "SQL Server" as MinDB { + database "Biergarten\nDatabase" as MinDBInner { + portin "1433" + } + } + + node "Migrations\n(run-once)" as MinMig { + component "Database.Migrations" + } + + node "Mailpit" as MinMailpit { + portin "8025 / 1025" + } + + node "Seed\n(run-once)" as MinSeed { + component "Database.Seed" + } + + note bottom of MinDB + Database + mailpit only, no API + container - for frontend development + against an API.Core process run + outside Docker. + end note + + MinMig --> MinDB : 1. Run migrations + MinSeed --> MinDB : 2. Seed data + MinMig .up.> MinDB : depends_on + MinSeed .up.> MinMig : depends_on +} + folder "test-results/\n(mounted volume)" as Results { file "api-specs/\n results.trx" as Result1 file "Features.Auth.Tests.trx" as Result2 @@ -156,6 +283,7 @@ Host --> DevAPI : http://localhost:8080 DevMig --> DevDB : 1. Run migrations DevSeed --> DevDB : 2. Seed data DevAPI --> DevDB : 3. Connect & serve +DevAPI ..> DevMailpit : 4. SMTP_HOST/PORT DevMig .up.> DevDB : depends_on DevSeed .up.> DevMig : depends_on DevAPI .up.> DevSeed : depends_on @@ -203,18 +331,4 @@ note top of Specs Results aggregated end note -' Production note -note as ProductionNote - Production Deployment (not shown): - - Would include: - • Azure SQL Database / AWS RDS - • Azure Container Apps / ECS - • Azure Key Vault for secrets - • Application Insights / CloudWatch - • Load balancer - • HTTPS termination - • CDN for static assets -end note - @enduml diff --git a/docs/website/docker.md b/docs/website/docker.md index d27c7c99..3a976ac0 100644 --- a/docs/website/docker.md +++ b/docs/website/docker.md @@ -34,7 +34,7 @@ representation. **Services**: ```yaml -sqlserver # SQL Server 2022 (port 1433) +sqlserver # SQL Server 2022 (port 1434) database.migrations # DbUp migrations database.seed # Seed initial data api.core # Web API (ports 8080, 8081) @@ -52,7 +52,7 @@ docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d - API Swagger: http://localhost:8080/swagger - Health Check: http://localhost:8080/health - Mailpit UI: http://localhost:8025 -- SQL Server: localhost:1433 (sa credentials from .env.dev) +- SQL Server: localhost:1434 (sa credentials from .env.dev) **Stop Environment**: @@ -128,6 +128,51 @@ api.core # Production API docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml up -d ``` +### 4. Database only (`docker-compose.db.yaml`) + +**Purpose**: Run just the database for local backend development outside Docker +(for example, `dotnet run` against a containerized SQL Server) + +**Services**: + +```yaml +sqlserver # SQL Server 2022 (port 1434) +database.migrations # DbUp migrations +database.seed # Seed initial data +``` + +No `api.core` or `mailpit` container. Uses `.env.dev`, same as development. + +**Start**: + +```bash +docker compose --env-file web/.env.dev -f web/docker-compose.db.yaml up -d +``` + +### 5. Minimal (`docker-compose.min.yaml`) + +**Purpose**: Local dev where the API runs outside Docker but the database and +mail capture still run in containers + +**Services**: + +```yaml +sqlserver # SQL Server 2022 (port 1433) +database.migrations # DbUp migrations +database.seed # Seed initial data +mailpit # Local dev SMTP server + UI (port 8025) +``` + +No `api.core` container. Uses `.env.local` instead of `.env.dev`, and exposes +SQL Server on the standard port 1433 (`docker-compose.db.yaml` and +`docker-compose.dev.yaml` both use 1434). + +**Start**: + +```bash +docker compose --env-file web/.env.local -f web/docker-compose.min.yaml up -d +``` + ## Service dependencies Docker Compose manages startup order using health checks: diff --git a/docs/website/environment-variables.md b/docs/website/environment-variables.md index c8e9ddd8..bf9baed4 100644 --- a/docs/website/environment-variables.md +++ b/docs/website/environment-variables.md @@ -30,6 +30,25 @@ Environment-specific `.env` files loaded via `env_file:` in docker-compose.yaml: - `.env.test` - Testing - `.env.prod` - Production +### Generating an env file + +`web/generate-env.sh` copies `.env.example` to a target file and replaces the +secret-bearing values (`DB_PASSWORD`, `ACCESS_TOKEN_SECRET`, +`REFRESH_TOKEN_SECRET`, `CONFIRMATION_TOKEN_SECRET`, `SMTP_USERNAME`, +`SMTP_PASSWORD`) with freshly randomized ones via `openssl`. Non-secret values +are left untouched. The output file is written with `600` permissions. + +```bash +cd web +./generate-env.sh .env.dev +``` + +Pass the output path, and optionally a template path, as arguments (default +output: `.env`, default template: `.env.example`). The script prompts before +overwriting an existing file. Randomized `SMTP_USERNAME`/`SMTP_PASSWORD` are dev +placeholders; replace them with real provider credentials for +staging/production. + ## Backend variables (.NET API) ### Database connection @@ -88,13 +107,15 @@ WEBSITE_BASE_URL=https://thebiergarten.app # Base URL for the website **Generate Secrets**: -**macOS/Linux - Generate a cryptographically secure base64 secret (127 raw bytes)** +**macOS/Linux - Generate a cryptographically secure base64 secret (127 raw +bytes)** ```bash openssl rand -base64 127 ``` -**Windows - PowerShell 5.1 compatible (cryptographically secure, 127 raw bytes)** +**Windows - PowerShell 5.1 compatible (cryptographically secure, 127 raw +bytes)** ```powershell $bytes = New-Object byte[] 127 diff --git a/docs/website/getting-started.md b/docs/website/getting-started.md index 440ea9aa..2dda2db5 100644 --- a/docs/website/getting-started.md +++ b/docs/website/getting-started.md @@ -21,6 +21,16 @@ cd the-biergarten-app ### 2. Configure backend environment variables +Generate `.env.dev` with freshly randomized secrets: + +```bash +cd web +./generate-env.sh .env.dev +cd .. +``` + +Or copy the template and fill in values manually: + ```bash cp web/.env.example web/.env.dev ``` @@ -38,7 +48,9 @@ CONFIRMATION_TOKEN_SECRET= WEBSITE_BASE_URL=http://localhost:3000 ``` -See [Environment Variables](environment-variables.md) for the full list. +See [Environment Variables](environment-variables.md) for the full list, and its +[Generating an env file](environment-variables.md#generating-an-env-file) +section for details on `generate-env.sh`. ### 3. Start the backend stack diff --git a/docs/website/testing.md b/docs/website/testing.md index a7fc13bb..356e7317 100644 --- a/docs/website/testing.md +++ b/docs/website/testing.md @@ -1,4 +1,3 @@ - # Testing This document describes the testing strategy and how to run tests for The @@ -128,8 +127,8 @@ npm run test:storybook:playwright ### Running Storybook tests with a visible browser -Both of the above run headless by default. To watch the browser while -debugging a failure: +Both of the above run headless by default. To watch the browser while debugging +a failure: ```bash # Playwright Storybook suite @@ -158,6 +157,9 @@ The default (`headless: true`) is set in `web/frontend/vite.config.ts`. - Refresh token exchange - JWT token generation, validation, and claims handling - Invalid credentials and 404 error responses (via API.Specs) +- Account management: username, email, password, and profile updates, and + account deletion (blocked while dependent posts, comments, photos, or follows + exist) **Features.Breweries.Tests**: @@ -259,7 +261,12 @@ Features.Auth.Tests/ │ ├── RegisterUserHandlerTests.cs │ ├── ConfirmUserHandlerTests.cs │ ├── ResendConfirmationEmailHandlerTests.cs -│ └── RefreshTokenHandlerTests.cs +│ ├── RefreshTokenHandlerTests.cs +│ ├── UpdateUsernameHandlerTests.cs +│ ├── UpdateEmailHandlerTests.cs +│ ├── UpdatePasswordHandlerTests.cs +│ ├── UpdateProfileHandlerTests.cs +│ └── DeleteAccountHandlerTests.cs ├── Queries/ │ └── LoginHandlerTests.cs # tests LoginCommand, despite the folder name └── Services/ diff --git a/docs/website/token-validation.md b/docs/website/token-validation.md index 6eb5b90e..55756252 100644 --- a/docs/website/token-validation.md +++ b/docs/website/token-validation.md @@ -112,6 +112,21 @@ middleware) also depends on it directly. - `POST /api/auth/confirm/resend?userId=...` - Resend the confirmation email - `POST /api/auth/refresh` - Refresh access token +Account management (require a valid access token; user ID comes from the token, +never the request body): + +- `PATCH /api/auth/username` - Change the authenticated user's username +- `PATCH /api/auth/email` - Change the authenticated user's email address; + resets `EmailConfirmed` to false, so the user must re-confirm via + `POST /api/auth/confirm/resend` +- `PATCH /api/auth/password` - Change the authenticated user's password; + requires the current password +- `PATCH /api/auth/profile` - Update the authenticated user's profile (first + name, last name, date of birth) +- `DELETE /api/auth/account` - Permanently delete the authenticated user's + account; blocked while the account still has associated posts, comments, + photos, or follows + ## Validation security ### Token secrets diff --git a/tooling/pipeline/format.sh b/tooling/pipeline/format.sh index 8aee13a7..870c872a 100755 --- a/tooling/pipeline/format.sh +++ b/tooling/pipeline/format.sh @@ -1,6 +1,5 @@ #!/bin/bash -# Check for -y flag SKIP_CONFIRM=false if [[ "$1" == "-y" ]]; then SKIP_CONFIRM=true diff --git a/tooling/pipeline/includes/concurrency/bounded_channel.h b/tooling/pipeline/includes/concurrency/bounded_channel.h index 71dda2cf..96ca2b19 100644 --- a/tooling/pipeline/includes/concurrency/bounded_channel.h +++ b/tooling/pipeline/includes/concurrency/bounded_channel.h @@ -67,7 +67,6 @@ class BoundedChannel { void Close(); }; -// Include the template implementation #include "bounded_channel.tcc" #endif // BIERGARTEN_PIPELINE_INCLUDES_CONCURRENCY_BOUNDED_CHANNEL_H_ diff --git a/tooling/pipeline/includes/services/database/sqlite_handle_types.h b/tooling/pipeline/includes/services/database/sqlite_handle_types.h index d92eee0e..71177273 100644 --- a/tooling/pipeline/includes/services/database/sqlite_handle_types.h +++ b/tooling/pipeline/includes/services/database/sqlite_handle_types.h @@ -24,7 +24,6 @@ using SqliteDatabaseHandle = std::unique_ptr; using SqliteStatementHandle = std::unique_ptr; -// Represents a parameter that is bound to a prepared SQLite statement. // N.B. indices are 1 based. template struct BoundParam { diff --git a/tooling/pipeline/includes/services/datetime/date_time_provider.h b/tooling/pipeline/includes/services/datetime/date_time_provider.h index eaf5b418..37b10e40 100644 --- a/tooling/pipeline/includes/services/datetime/date_time_provider.h +++ b/tooling/pipeline/includes/services/datetime/date_time_provider.h @@ -15,9 +15,6 @@ /** * @brief Interface for UTC timestamp providers. - * - * @todo Yagni, consider removing this and just use SystemDateTimeProvider - * directly */ class IDateTimeProvider { public: diff --git a/tooling/pipeline/runpod/start.sh b/tooling/pipeline/runpod/start.sh index 8deabaa7..e9b65bff 100644 --- a/tooling/pipeline/runpod/start.sh +++ b/tooling/pipeline/runpod/start.sh @@ -9,12 +9,10 @@ PROMPT_DIR="/app/prompts" echo "--- Starting Biergarten Pipeline Environment Check ---" -# Ensure directories exist mkdir -p "$OUTPUT_DIR" mkdir -p "$(dirname "$LOG_PATH")" mkdir -p "$(dirname "$MODEL_PATH")" -# Download model if missing if [ ! -f "$MODEL_PATH" ]; then echo "Model not found. Downloading (this may take a while)..." @@ -25,16 +23,13 @@ if [ ! -f "$MODEL_PATH" ]; then echo "Download complete." fi -# Verify model exists if [ ! -f "$MODEL_PATH" ]; then echo "ERROR: Model still not found after download attempt." exit 1 fi -# Default GPU layers GL_LAYERS="${BIERGARTEN_GL_LAYERS:-40}" -# Build args ARGS=( "--model" "$MODEL_PATH" "--prompt-dir" "$PROMPT_DIR" @@ -43,14 +38,12 @@ ARGS=( "--n-gpu-layers" "$GL_LAYERS" ) -# Optional params [[ -n "$BIERGARTEN_TEMPERATURE" ]] && ARGS+=("--temperature" "$BIERGARTEN_TEMPERATURE") [[ -n "$BIERGARTEN_TOP_P" ]] && ARGS+=("--top-p" "$BIERGARTEN_TOP_P") [[ -n "$BIERGARTEN_TOP_K" ]] && ARGS+=("--top-k" "$BIERGARTEN_TOP_K") [[ -n "$BIERGARTEN_N_CTX" ]] && ARGS+=("--n-ctx" "$BIERGARTEN_N_CTX") [[ -n "$BIERGARTEN_SEED" ]] && ARGS+=("--seed" "$BIERGARTEN_SEED") -# Extra args [[ -n "$BIERGARTEN_EXTRA_ARGS" ]] && ARGS+=($BIERGARTEN_EXTRA_ARGS) echo "--- Executing: $EXECUTABLE ${ARGS[*]} ---" diff --git a/tooling/pipeline/src/data_generation/generation_json_validation.cc b/tooling/pipeline/src/data_generation/generation_json_validation.cc index 29ce700d..b8558fc7 100644 --- a/tooling/pipeline/src/data_generation/generation_json_validation.cc +++ b/tooling/pipeline/src/data_generation/generation_json_validation.cc @@ -17,9 +17,6 @@ #include namespace { -/** - * String trimming: removes leading and trailing whitespace - */ std::string Trim(std::string_view value) { constexpr std::string_view whitespace = " \t\n\r\f\v"; const size_t first_index = value.find_first_not_of(whitespace); diff --git a/tooling/pipeline/src/services/enrichment/wikipedia/fetch_extract.cc b/tooling/pipeline/src/services/enrichment/wikipedia/fetch_extract.cc index 8cd6d169..79eac1b8 100644 --- a/tooling/pipeline/src/services/enrichment/wikipedia/fetch_extract.cc +++ b/tooling/pipeline/src/services/enrichment/wikipedia/fetch_extract.cc @@ -17,7 +17,6 @@ using namespace boost; std::string WikipediaEnrichmentService::FetchExtract(std::string_view query) { const std::string cache_key(query); - // 1. Cache Lookup if (const auto cache_it = this->extract_cache_.find(cache_key); cache_it != this->extract_cache_.end()) { if (logger_) { @@ -42,7 +41,6 @@ std::string WikipediaEnrichmentService::FetchExtract(std::string_view query) { std::this_thread::sleep_for(1s); } - // 2. Parse JSON system::error_code ec; json::value doc = json::parse(body, ec); @@ -57,7 +55,6 @@ std::string WikipediaEnrichmentService::FetchExtract(std::string_view query) { return {}; } - // 3. Safe Extraction const json::object* obj = doc.if_object(); if (obj == nullptr) { if (logger_) { @@ -118,7 +115,6 @@ std::string WikipediaEnrichmentService::FetchExtract(std::string_view query) { const json::object& page = page_val.get_object(); - // Handle 404/Missing status if (page.contains("missing")) { if (logger_) { logger_->Log({.level = LogLevel::Warn, @@ -145,7 +141,6 @@ std::string WikipediaEnrichmentService::FetchExtract(std::string_view query) { return {}; } - // 4. Success std::string extract(extract_ptr->as_string()); if (logger_) { logger_->Log( diff --git a/tooling/pipeline/src/services/prompt_directory.cc b/tooling/pipeline/src/services/prompt_directory.cc index 858d82cd..2485e4fc 100644 --- a/tooling/pipeline/src/services/prompt_directory.cc +++ b/tooling/pipeline/src/services/prompt_directory.cc @@ -15,8 +15,6 @@ #include #include -// PromptDirectory - PromptDirectory::PromptDirectory(const std::filesystem::path& prompt_dir) : PromptDirectory(prompt_dir, nullptr) {} @@ -25,14 +23,12 @@ PromptDirectory::PromptDirectory(const std::filesystem::path& prompt_dir, : prompt_dir_(prompt_dir), logger_(std::move(logger)) { std::error_code ec; - // Directory must exist. if (!std::filesystem::exists(prompt_dir_, ec) || ec) { throw std::runtime_error( "PromptDirectory: prompt directory does not exist: " + prompt_dir_.string()); } - // Path must be a directory, not a file. if (!std::filesystem::is_directory(prompt_dir_, ec) || ec) { throw std::runtime_error( "PromptDirectory: prompt directory path is not a directory: " + @@ -96,7 +92,6 @@ std::string PromptDirectory::Load(std::string_view key) { key_str, file_path.string(), content.size())}); } - // Cache the loaded content for future calls. cache_.emplace(key_str, content); return content; } diff --git a/tooling/pipeline/src/services/xeger/xeger_generator.cc b/tooling/pipeline/src/services/xeger/xeger_generator.cc index 82aca295..ae0390ac 100644 --- a/tooling/pipeline/src/services/xeger/xeger_generator.cc +++ b/tooling/pipeline/src/services/xeger/xeger_generator.cc @@ -25,12 +25,11 @@ std::string XegerGenerator::Run(const Node& root) { void XegerGenerator::Visit(const Literal& node) { out_ += node.ch; } void XegerGenerator::Visit(const AnyChar&) { - static constexpr int kPrintableLow = ' '; // empty space - character 32 - static constexpr int kPrintableHigh = '~'; // tilde - character 126 + static constexpr int kPrintableLow = ' '; + static constexpr int kPrintableHigh = '~'; std::uniform_int_distribution dist(kPrintableLow, kPrintableHigh); - // assert that the rand number is in the char range bool inCharRange = dist.min() >= std::numeric_limits::min() && dist.max() <= std::numeric_limits::max(); assert(inCharRange && "Random number is out of char range"); diff --git a/web/backend/API/API.Core/Authentication/JwtAuthenticationHandler.cs b/web/backend/API/API.Core/Authentication/JwtAuthenticationHandler.cs index 5a535eb5..e0de10d6 100644 --- a/web/backend/API/API.Core/Authentication/JwtAuthenticationHandler.cs +++ b/web/backend/API/API.Core/Authentication/JwtAuthenticationHandler.cs @@ -39,7 +39,6 @@ public class JwtAuthenticationHandler( if (string.IsNullOrWhiteSpace(secret)) return AuthenticateResult.Fail("JWT secret is not configured"); - // Check if Authorization header exists if (!Request.Headers.TryGetValue("Authorization", out StringValues authHeaderValue)) return AuthenticateResult.Fail("Authorization header is missing"); diff --git a/web/backend/API/API.Core/Controllers/NotFoundController.cs b/web/backend/API/API.Core/Controllers/NotFoundController.cs index 2f0ee490..f232fc33 100644 --- a/web/backend/API/API.Core/Controllers/NotFoundController.cs +++ b/web/backend/API/API.Core/Controllers/NotFoundController.cs @@ -11,13 +11,13 @@ namespace API.Core.Controllers; /// [ApiController] [ApiExplorerSettings(IgnoreApi = true)] -[Route("error")] // required +[Route("error")] public class NotFoundController : ControllerBase { /// /// Returns a 404 response for any request that did not match a route. /// - [HttpGet("404")] //required + [HttpGet("404")] public IActionResult Handle404() { return NotFound(new { message = "Route not found." }); diff --git a/web/backend/API/API.Core/GlobalException.cs b/web/backend/API/API.Core/GlobalException.cs index d9fd376e..1ab60148 100644 --- a/web/backend/API/API.Core/GlobalException.cs +++ b/web/backend/API/API.Core/GlobalException.cs @@ -1,5 +1,3 @@ -// API.Core/Filters/GlobalExceptionFilter.cs - using Domain.Exceptions; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; diff --git a/web/backend/API/API.Core/Program.cs b/web/backend/API/API.Core/Program.cs index c2a266c1..0a3cf8a9 100644 --- a/web/backend/API/API.Core/Program.cs +++ b/web/backend/API/API.Core/Program.cs @@ -61,7 +61,6 @@ builder.Services.AddSwaggerGen(options => }); builder.Services.AddOpenApi(); -// Add FluentValidation builder.Services.AddValidatorsFromAssemblyContaining(); builder.Services.AddValidatorsFromAssembly(typeof(BreweryController).Assembly); builder.Services.AddValidatorsFromAssembly(typeof(UserController).Assembly); @@ -80,7 +79,6 @@ builder.Services.AddMediatR(cfg => cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); }); -// Add health checks builder.Services.AddHealthChecks(); // Configure logging for container output @@ -89,8 +87,6 @@ builder.Logging.AddConsole(); if (!builder.Environment.IsProduction()) builder.Logging.AddDebug(); -// Configure Dependency Injection ------------------------------------------------------------------------------------- - builder.Services.AddInfrastructureSql(); builder.Services.AddFeaturesBreweries(); @@ -101,10 +97,8 @@ builder.Services.AddFeaturesEmails(); // ITokenInfrastructure is registered here because JwtAuthenticationHandler depends on it directly. builder.Services.AddScoped(); -// Register the exception filter builder.Services.AddScoped(); -// Configure JWT Authentication builder .Services.AddAuthentication("JWT") .AddScheme("JWT", options => { }); @@ -128,7 +122,6 @@ app.MapHealthChecks("/health"); app.MapControllers(); app.MapFallbackToController("Handle404", "NotFound"); -// Graceful shutdown handling IHostApplicationLifetime lifetime = app.Services.GetRequiredService(); lifetime.ApplicationStopping.Register(() => { diff --git a/web/backend/API/API.Specs/Steps/AuthSteps.cs b/web/backend/API/API.Specs/Steps/AuthSteps.cs index 3efe67c0..444fa624 100644 --- a/web/backend/API/API.Specs/Steps/AuthSteps.cs +++ b/web/backend/API/API.Specs/Steps/AuthSteps.cs @@ -264,7 +264,6 @@ public class AuthSteps(ScenarioContext scenario) public async Task WhenISubmitALoginRequestUsingAgetRequest() { HttpClient client = GetClient(); - // testing GET HttpRequestMessage requestMessage = new(HttpMethod.Get, "/api/auth/login") { Content = new StringContent("{}", Encoding.UTF8, "application/json"), @@ -703,7 +702,6 @@ public class AuthSteps(ScenarioContext scenario) [Given("I have an access token signed with the wrong secret")] public void GivenIHaveAnAccessTokenSignedWithTheWrongSecret() { - // Create a token with a different secret scenario["accessToken"] = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; } diff --git a/web/backend/API/API.Specs/TestApiFactory.cs b/web/backend/API/API.Specs/TestApiFactory.cs index 2c55a164..d11c87d3 100644 --- a/web/backend/API/API.Specs/TestApiFactory.cs +++ b/web/backend/API/API.Specs/TestApiFactory.cs @@ -15,7 +15,6 @@ public class TestApiFactory : WebApplicationFactory builder.ConfigureServices(services => { - // Replace the real email provider with mock for testing ServiceDescriptor? emailProviderDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IEmailProvider) ); @@ -25,7 +24,6 @@ public class TestApiFactory : WebApplicationFactory services.AddScoped(); - // Replace the real email dispatcher with mock for testing ServiceDescriptor? emailDispatcherDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IEmailDispatcher) ); diff --git a/web/backend/Domain/Domain.Exceptions/Exceptions.cs b/web/backend/Domain/Domain.Exceptions/Exceptions.cs index bdb5101d..6243c154 100644 --- a/web/backend/Domain/Domain.Exceptions/Exceptions.cs +++ b/web/backend/Domain/Domain.Exceptions/Exceptions.cs @@ -11,8 +11,6 @@ public class ConflictException(string message) : Exception(message); /// public class NotFoundException(string message) : Exception(message); -// Domain.Exceptions/UnauthorizedException.cs - /// /// The exception that is thrown when authentication fails or is required. Maps to HTTP 401 Unauthorized. /// diff --git a/web/backend/Features/Features.Auth.Tests/Services/TokenServiceRefreshTests.cs b/web/backend/Features/Features.Auth.Tests/Services/TokenServiceRefreshTests.cs index a9bae466..b78bd5ae 100644 --- a/web/backend/Features/Features.Auth.Tests/Services/TokenServiceRefreshTests.cs +++ b/web/backend/Features/Features.Auth.Tests/Services/TokenServiceRefreshTests.cs @@ -62,12 +62,10 @@ public class TokenServiceRefreshTests ApplicationUser user = new() { Id = userId, UserName = username }; - // Mock the validation of refresh token _tokenInfraMock .Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny())) .ReturnsAsync(principal); - // Mock the generation of new tokens _tokenInfraMock .Setup(x => x.GenerateJwt(userId, username, It.IsAny(), It.IsAny())) .Returns( diff --git a/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs b/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs index bb8d4c65..93e8629d 100644 --- a/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs +++ b/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs @@ -66,7 +66,6 @@ public class RegisterUserHandler( { await Console.Error.WriteLineAsync(ex.Message); Console.WriteLine("Could not send email."); - // ignored } return new RegistrationPayload(user.Id, user.UserName, refreshToken, accessToken, emailSent); diff --git a/web/backend/Features/Features.Auth/Services/TokenService.cs b/web/backend/Features/Features.Auth/Services/TokenService.cs index 1c697874..ad40a935 100644 --- a/web/backend/Features/Features.Auth/Services/TokenService.cs +++ b/web/backend/Features/Features.Auth/Services/TokenService.cs @@ -20,7 +20,6 @@ public class TokenService : ITokenService private readonly ITokenInfrastructure _tokenInfrastructure; private readonly UserManager _userManager; - /// Initializes a new instance of the class. /// /// Thrown when any of the ACCESS_TOKEN_SECRET, REFRESH_TOKEN_SECRET, or /// CONFIRMATION_TOKEN_SECRET configuration values are not set. diff --git a/web/backend/Features/Features.UserManagement/Commands/UpdateUser/UpdateUserHandler.cs b/web/backend/Features/Features.UserManagement/Commands/UpdateUser/UpdateUserHandler.cs index 1daae7af..4967b127 100644 --- a/web/backend/Features/Features.UserManagement/Commands/UpdateUser/UpdateUserHandler.cs +++ b/web/backend/Features/Features.UserManagement/Commands/UpdateUser/UpdateUserHandler.cs @@ -3,9 +3,6 @@ using MediatR; namespace Features.UserManagement.Commands.UpdateUser; -/// -/// Handles . -/// public class UpdateUserHandler(IUserAccountRepository repository) : IRequestHandler { diff --git a/web/backend/Features/Features.UserManagement/Queries/GetAllUsers/GetAllUsersHandler.cs b/web/backend/Features/Features.UserManagement/Queries/GetAllUsers/GetAllUsersHandler.cs index bc0822ec..4e8afe82 100644 --- a/web/backend/Features/Features.UserManagement/Queries/GetAllUsers/GetAllUsersHandler.cs +++ b/web/backend/Features/Features.UserManagement/Queries/GetAllUsers/GetAllUsersHandler.cs @@ -4,9 +4,6 @@ using MediatR; namespace Features.UserManagement.Queries.GetAllUsers; -/// -/// Handles . -/// public class GetAllUsersHandler(IUserAccountRepository repository) : IRequestHandler> { diff --git a/web/backend/Features/Features.UserManagement/Queries/GetUserById/GetUserByIdHandler.cs b/web/backend/Features/Features.UserManagement/Queries/GetUserById/GetUserByIdHandler.cs index bab1687e..d664310e 100644 --- a/web/backend/Features/Features.UserManagement/Queries/GetUserById/GetUserByIdHandler.cs +++ b/web/backend/Features/Features.UserManagement/Queries/GetUserById/GetUserByIdHandler.cs @@ -5,9 +5,6 @@ using MediatR; namespace Features.UserManagement.Queries.GetUserById; -/// -/// Handles . -/// public class GetUserByIdHandler(IUserAccountRepository repository) : IRequestHandler { diff --git a/web/backend/Features/Features.UserManagement/Queries/GetUserById/GetUserByIdQuery.cs b/web/backend/Features/Features.UserManagement/Queries/GetUserById/GetUserByIdQuery.cs index 51d47c2f..8337b7a9 100644 --- a/web/backend/Features/Features.UserManagement/Queries/GetUserById/GetUserByIdQuery.cs +++ b/web/backend/Features/Features.UserManagement/Queries/GetUserById/GetUserByIdQuery.cs @@ -3,7 +3,4 @@ using MediatR; namespace Features.UserManagement.Queries.GetUserById; -/// -/// Requests the user account with the given ID. -/// public record GetUserByIdQuery(Guid UserAccountId) : IRequest; diff --git a/web/backend/Infrastructure/Infrastructure.Email/SmtpEmailProvider.cs b/web/backend/Infrastructure/Infrastructure.Email/SmtpEmailProvider.cs index 70f6741e..724eb2d4 100644 --- a/web/backend/Infrastructure/Infrastructure.Email/SmtpEmailProvider.cs +++ b/web/backend/Infrastructure/Infrastructure.Email/SmtpEmailProvider.cs @@ -93,14 +93,12 @@ public class SmtpEmailProvider : IEmailProvider try { - // Determine the SecureSocketOptions based on SSL setting SecureSocketOptions secureSocketOptions = _useSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.None; await client.ConnectAsync(_host, _port, secureSocketOptions); - // Authenticate if credentials are provided if (!string.IsNullOrEmpty(_username) && !string.IsNullOrEmpty(_password)) await client.AuthenticateAsync(_username, _password); diff --git a/web/backend/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs b/web/backend/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs index 0f6199d7..fe7f5d5d 100644 --- a/web/backend/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs +++ b/web/backend/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs @@ -12,9 +12,6 @@ public interface ITokenInfrastructure /// string GenerateJwt(Guid userId, string username, DateTime expiry, string secret); - /// - /// Validates a JWT and returns the resulting claims principal. - /// /// Thrown when the token is invalid, expired, or fails validation. Task ValidateJwtAsync(string token, string secret); } diff --git a/web/backend/Infrastructure/Infrastructure.Sql/DefaultSqlConnectionFactory.cs b/web/backend/Infrastructure/Infrastructure.Sql/DefaultSqlConnectionFactory.cs index e93396eb..c38500ba 100644 --- a/web/backend/Infrastructure/Infrastructure.Sql/DefaultSqlConnectionFactory.cs +++ b/web/backend/Infrastructure/Infrastructure.Sql/DefaultSqlConnectionFactory.cs @@ -26,7 +26,6 @@ public class DefaultSqlConnectionFactory(IConfiguration configuration) : ISqlCon /// Thrown when no connection string can be resolved from any source. private static string GetConnectionString(IConfiguration configuration) { - // Check for full connection string first string? fullConnectionString = configuration["DB_CONNECTION_STRING"]; if (!string.IsNullOrEmpty(fullConnectionString)) return fullConnectionString; diff --git a/web/backend/Infrastructure/Infrastructure.Sql/SqlConnectionStringHelper.cs b/web/backend/Infrastructure/Infrastructure.Sql/SqlConnectionStringHelper.cs index 8512ce33..9c03babb 100644 --- a/web/backend/Infrastructure/Infrastructure.Sql/SqlConnectionStringHelper.cs +++ b/web/backend/Infrastructure/Infrastructure.Sql/SqlConnectionStringHelper.cs @@ -38,9 +38,6 @@ public static class SqlConnectionStringHelper return builder.ConnectionString; } - /// - /// Builds a connection string to the master database using configuration. - /// public static string BuildMasterConnectionString(IConfiguration configuration) { return BuildConnectionString(configuration, "master"); diff --git a/web/backend/Shared/Shared.Contracts/ResponseBody.cs b/web/backend/Shared/Shared.Contracts/ResponseBody.cs index d3f4e690..2326814a 100644 --- a/web/backend/Shared/Shared.Contracts/ResponseBody.cs +++ b/web/backend/Shared/Shared.Contracts/ResponseBody.cs @@ -5,10 +5,8 @@ namespace Shared.Contracts; /// public record ResponseBody { - /// Gets the human-readable message describing the response. public required string Message { get; init; } - /// Gets the response's data payload. public required T Payload { get; init; } } @@ -17,6 +15,5 @@ public record ResponseBody /// public record ResponseBody { - /// Gets the human-readable message describing the response. public required string Message { get; init; } } diff --git a/web/frontend/.storybook/vitest.setup.ts b/web/frontend/.storybook/vitest.setup.ts index ea170b04..62899f00 100644 --- a/web/frontend/.storybook/vitest.setup.ts +++ b/web/frontend/.storybook/vitest.setup.ts @@ -2,6 +2,5 @@ import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview'; import { setProjectAnnotations } from '@storybook/react-vite'; import * as projectAnnotations from './preview'; -// This is an important step to apply the right configuration when testing your stories. // More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]); diff --git a/web/frontend/stories/Themes.stories.tsx b/web/frontend/stories/Themes.stories.tsx index 566724d4..b455a1d0 100644 --- a/web/frontend/stories/Themes.stories.tsx +++ b/web/frontend/stories/Themes.stories.tsx @@ -53,7 +53,6 @@ const ThemePanel = ({ label, value, vibe }: { label: string; value: string; vibe

{vibe}

- {/* Core palette */}

Core @@ -69,7 +68,6 @@ const ThemePanel = ({ label, value, vibe }: { label: string; value: string; vibe

- {/* Status tokens */}

Status @@ -82,7 +80,6 @@ const ThemePanel = ({ label, value, vibe }: { label: string; value: string; vibe

- {/* Content tokens (custom) */}

Content diff --git a/web/frontend/vite.config.ts b/web/frontend/vite.config.ts index 99ba2e7b..f3ce9534 100644 --- a/web/frontend/vite.config.ts +++ b/web/frontend/vite.config.ts @@ -21,7 +21,6 @@ export default defineConfig({ { extends: true, plugins: [ - // The plugin will run tests for the stories defined in your Storybook config // See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest storybookTest({ configDir: path.join(dirname, '.storybook'),