Onboarding guide
15. Common UI Framework (MudBlazor components, theme, base pages)
What this chapter covers. MMCA.Common.UI is the Blazor presentation package, and it is one of
the two layers (with Grpc) allowed to reference Shared only: its single ProjectReference is
MMCA.Common.Shared (MMCA.Common.UI/MMCA.Common.UI.csproj:42), and every other dependency is a
NuGet package (MudBlazor, Polly, the SignalR client, Scrutor, QRCoder, FeatureManagement,
System.IdentityModel.Tokens.Jwt, MMCA.Common.UI.csproj:19-37). It touches no Application, Domain
or Infrastructure type, which is exactly what lets it compile into a Blazor WebAssembly bundle and
into a .NET MAUI hybrid head (see primer §1). What it ships is the
set of reusable parts every consumer UI assembles pages from: a server-paged data-grid list-page
base class, the brand MudBlazor theme, a Result-returning typed HTTP service base for
talking to the WebAPI, a client-side read cache, the authentication and token-refresh
boundary, list-page state preservation across navigation, vendor-neutral toast and dialog
facades, a pluggable UI-module contract, an end-to-end localization pipeline, drop-in role
and user administration screens, and a turnkey notification inbox / push / live-channel feature.
A second, thinner package MMCA.Common.UI.Web sits above it and holds the pieces that need an ASP.NET
pipeline (server-side token storage, the Blazor Content-Security-Policy provider, the
trusted-caller header an SSR host stamps on its gateway calls, and the host's own edge rate limiter
and ceiling on open circuits, plus an opt-in same-origin API proxy that keeps every token out of script). The per-app and per-module Razor pages in the consumer apps
(chapter 21) derive from and consume these primitives, and the same
components render across Blazor Server, WebAssembly and MAUI with no per-platform reimplementation.
[Rubric §18, UI Architecture & Component Design] assesses component reuse, separation of
presentation from data access, and whether there is a coherent composition model; nearly every type
in this group exists so a consumer page is composed rather than hand-rolled, which is the shape
ADR-067 records.
The data-access boundary: a Result contract over one named HttpClient. A page never touches
HttpClient, and it never catches an exception to learn what the server said. It depends on
IEntityService<TEntityDTO, TIdentifierType>
(MMCA.Common.UI/Common/Interfaces/IEntityService.cs:20), whose seven CRUD members every one return
a Result or Result<T>
(IEntityService.cs:25-66), and it gets its behavior from the abstract
EntityServiceBase<TEntityDTO, TIdentifierType>
(MMCA.Common.UI/Services/Api/EntityServiceBase.cs:43), which derives in turn from
AuthenticatedServiceBase
(MMCA.Common.UI/Services/Api/AuthenticatedServiceBase.cs:16). That base owns the cross-cutting concerns
of an outbound call. First, a Polly retry policy: 3 retries with exponential backoff (2s, 4s, 8s)
plus up to one second of random jitter so a fleet of clients does not re-converge on the same instant
(AuthenticatedServiceBase.cs:20-27, :153-155), over a deliberate retryable set rather than "any
5xx", since 501 and 505 are permanent verdicts and are excluded while 408 and 429 are explicit
invitations to come back (AuthenticatedServiceBase.cs:97-122). A POST or PATCH is replayed only
when its request carried an Idempotency-Key, because re-sending a write the server cannot dedupe
could apply it twice (:115-118, :134-148). The policy's onRetry disposes each
superseded response, because Polly hands the caller only the final outcome and an undisposed 5xx
leaks its content buffer and holds its connection out of the pool under exactly the sustained failure
the retries exist to survive (AuthenticatedServiceBase.cs:163-173). Second, a helper that creates a
"APIClient" HttpClient from IHttpClientFactory and stamps the JWT Bearer token onto it from
ITokenStorageService, swallowing the InvalidOperationException that JS
interop throws during SSR prerender (AuthenticatedServiceBase.cs:53-78); a sibling
CreateClientWithToken builds a client around an explicitly supplied token so a request the API
answered 401 can be replayed with one acquired straight from ITokenRefresher
rather than resending the token the server just rejected (AuthenticatedServiceBase.cs:80-95).
The same policy object is reachable without inheritance: IdempotentReadRetry
(MMCA.Common.UI/Services/Api/IdempotentReadRetry.cs:18) hands anonymous lookup services the exact
instance the base exposes to its subclasses, so the two paths cannot drift, and its only entry point
is a GET because re-sending is safe only for an idempotent read (IdempotentReadRetry.cs:9-16, :24,
:35-44, AuthenticatedServiceBase.cs:34-38). Its read-side sibling PagedReadAll
(MMCA.Common.UI/Services/Api/PagedReadAll.cs:19) exists because a single read truncates silently at
the API's page-size maximum: it pages a /paged endpoint in stable id order with a PageSize of 500
until the server's reported total is reached, hands back the first failure unchanged, and stops after
40 pages (20,000 rows) whatever the server claims (PagedReadAll.cs:12-18, :22, :28, :43-46,
:57-90).
Retry and idempotency are coupled on purpose: NewIdempotencyKey()
(AuthenticatedServiceBase.cs:51) is generated once per logical write and set as a default header
on the single client that serves every attempt (EntityServiceBase.cs:168-175, :399-414), so a
retried create dedupes on the server instead of producing a duplicate row (the server half is
IdempotencyHeaders and
IdempotentAttribute,
ADR-017). Creates are the only
verb that carries a key: updates are full PUTs and deletes are naturally idempotent
(EntityServiceBase.cs:168-170). Updates instead carry a precondition: ConcurrencyTagOf renders a
DTO's RowVersion as a weak entity tag when the DTO implements
IConcurrencyAware, and that tag travels as the
If-Match header on the same per-operation client, so every retry states the same precondition
instead of a later attempt succeeding against a version the user never saw
(EntityServiceBase.cs:193-209, :416-424,
ADR-035). Responses come back
in the same PagedCollectionResult<T> /
CollectionResult<T> envelopes the API
returns, and both SendRequestAsync overloads read the response through
ProblemDetailsResultReader
(EntityServiceBase.cs:351-366, :381-394), so a 404 arrives as an
ErrorType.NotFound failure, a rejection as
Validation, a 500 as Unexpected, each carrying the server's own text. What the reader cannot
convert is the absence of a response, and that is
HttpResultExecutor's job
(MMCA.Common.UI/Services/Api/HttpResultExecutor.cs:35): it wraps each call, turns a refused connection,
a broken stream or an unreadable body into Http.TransportFailure and a client-side timeout into
Http.Timeout (HttpResultExecutor.cs:38, :41, :135-136), and rethrows an
OperationCanceledException only when the caller's own token asked for it, because a page owns
its cancellation and must not have a disposed component reported back as an error to render
(HttpResultExecutor.cs:69-75, :104-110). Many-to-many join endpoints, which have POST and DELETE but no
standalone reads, get their own thinner base, ChildEntityServiceBase
(MMCA.Common.UI/Services/Api/ChildEntityServiceBase.cs:19), whose DeleteByIdAsync reports a missing
join row as a NotFound failure so "nothing to remove" stays distinguishable from "the remove
failed" (ChildEntityServiceBase.cs:62-79). The page-side half of the same transport is
ResultUiExtensions
(MMCA.Common.UI/Common/ResultUiExtensions.cs:74): TryGetValue unwraps inside a conditional the way
Dictionary.TryGetValue does, branching on IsFailure rather than on a null so a failed
(Items, TotalItems) tuple is not read as a success (ResultUiExtensions.cs:86-103), and the
rendering helpers look every message up as a resource key with pass-through, de-duplicate ordinally,
and order most severe first so a real 403 leads and an incidental validation line never buries it
(ResultUiExtensions.cs:18-29). [Rubric §3, Clean Architecture] and [Rubric §9, API & Contract Design]: the UI binds to a DTO contract and an interface, never to server internals, and the wire
envelope is uniform across every entity. [Rubric §29, Resilience] is the retry, jitter, idempotency
and precondition set. The contract itself is recorded in
ADR-094, and the retirement
of the old exception-throwing UI deviation in
ADR-013.
A third caching tier, on the client. IUiReadCache
(MMCA.Common.UI/Services/Caching/IUiReadCache.cs:32) is a read-through cache sitting in front of the
API client, so a list re-read twice within a few seconds (a grid re-mounted by navigation, a lookup
rendered in two components) costs one round trip. Its operations are TryGetFresh, Set,
InvalidatePrefix and Clear (IUiReadCache.cs:50, :59, :78, :85), plus a Generation counter that moves on every
invalidation (:35-40), and the key is
deliberately the relative URL, path plus the full query string, which is the same key shape the
server's authenticated output cache uses, so a filter, page or sort change misses on both tiers
rather than being served stale by one of them (IUiReadCache.cs:9-15,
ADR-040).
UiReadCache (MMCA.Common.UI/Services/Caching/UiReadCache.cs:18) implements it as a
lock-guarded ordinal dictionary (UiReadCache.cs:20, :25) with lazy expiry, dropping a stale
entry when it is next read instead of running a sweep timer over the few dozen entries a circuit ever
holds (UiReadCache.cs:12-14, :68, :76), and TTL resolution picks the longest matching route
prefix so a child route can state a different budget than the endpoint it sits under
(UiReadCache.cs:157-178). Staleness is configuration, not accident:
UiReadCacheOptions
(MMCA.Common.UI/Common/Settings/UiReadCacheOptions.cs:13) binds the UiReadCache section with an
Enabled escape hatch, a 60-second DefaultTtl and the per-prefix override map
(UiReadCacheOptions.cs:16, :24, :32, :41). EntityServiceBase takes the cache as an
optional constructor argument, so a service registered without one behaves exactly like a plain
GET (EntityServiceBase.cs:47, :58, :253-284), and a bypassCache flag forces a round trip
for a read the user explicitly asked to be current (:243-247); only successes are stored, because caching a
failure would pin a transient outage in front of the user for the whole TTL and let a 404 survive the
create that fixed it (EntityServiceBase.cs:276-281), while the generation captured before the GET
drops a late store that a concurrent write has already made stale (:270-273,
IUiReadCache.cs:63-70); and every write invalidates its own endpoint prefix unless the server
refused it before touching state (a validation, unprocessable, unauthorized, forbidden or
rate-limited answer), because a 412, 404 or 409 can be the answer to the retry of a write whose first
attempt landed, and a 5xx or transport failure says nothing about whether it did
(EntityServiceBase.cs:291-314). The one cross-cutting hazard is scope: the cache is
scoped, which is per-circuit on Blazor Server but per app lifetime on WebAssembly and MAUI, so
AuthUIService.LogoutAsync clears it explicitly rather than trusting the scope to
end with the session (MMCA.Common.UI/Services/Auth/AuthUIService.cs:88, the clear at :190; the
registration states why at MMCA.Common.UI/DependencyInjection.cs:73-76); the device-local document
cache that backs offline list snapshots is wiped in the same pass, since those snapshots are keyed by
surface rather than by user (the optional ILocalCacheStore dependency, AuthUIService.cs:51). [Rubric §12, Performance & Scalability] and [Rubric §19, State Management] both land here, and the tier is recorded in
ADR-026.
The list page: DataGridListPageBase<TDto>. This is the most concept-dense type in the group and
the centerpiece of the compose-do-not-repeat thesis. Every list screen in every consumer app derives
from DataGridListPageBase<TDto>
(MMCA.Common.UI/Pages/Common/DataGridListPageBase.cs:22), a ComponentBase that encapsulates what
would otherwise be copy-pasted onto each page: server-side paging against MudDataGrid<T>,
CancellationTokenSource lifecycle, loading state, filter and sort extraction from MudBlazor's
GridState<T>, error surfacing through the toast facade, a LoadFailed flag so a failed fetch
renders an inline retry instead of a misleading "no records" empty state
(DataGridListPageBase.cs:53, set at :752 and :773), viewport-driven mobile versus desktop
rendering (it implements IBrowserViewportObserver and flips IsMobile through
BreakpointConstants at the 960 px sidebar-collapse boundary,
DataGridListPageBase.cs:22, :322, and
MMCA.Common.UI/Common/BreakpointConstants.cs:16-17), a persisted dense-density toggle
(DataGridListPageBase.cs:83), and a careful IAsyncDisposable/IDisposable teardown. The fetch
path is Result-shaped end to end: a failed page toasts through
NotifyOnFailure(Toast, Localizer) and returns an empty grid rather than throwing
(DataGridListPageBase.cs:772-773). It also solves a Blazor render-mode problem: grid data captured
during SSR prerender is persisted through PersistentComponentState as a private
PersistedGridState record (DataGridListPageBase.cs:1132), restored in
OnInitialized (:179-189) and re-registered for persisting with an explicit
RenderMode.InteractiveAuto, because a page that inherits its render mode from <Routes> gives the
framework nothing to associate the callback with (DataGridListPageBase.cs:191-208,
ADR-056). A
PrerenderFetchTimeoutMs of 5000 caps how long prerender may block on a cold backend before falling
back to an empty grid the first interactive fetch refills (DataGridListPageBase.cs:95, applied at
:820). That fetch links the timeout to the page's own lifetime token, read through
ComponentLifetimeExtensions.LifetimeToken
(DataGridListPageBase.cs:816-817, MMCA.Common.UI/Common/ComponentLifetimeExtensions.cs:18, :26):
reading Token off a disposed CancellationTokenSource throws ObjectDisposedException, so a load
that resumed after the user navigated away would crash the circuit, whereas the extension returns an
already-cancelled token and the work stops through its ordinary cancellation path
(ComponentLifetimeExtensions.cs:3-11, :28-41). LatestLoadGuard and DetailPageBase read their
tokens the same way (LatestLoadGuard.cs:58, DetailPageBase.cs:36), and
LifetimeTokenConventionTestsBase pins the rule in each repo (ComponentLifetimeExtensions.cs:13-14). [Rubric §23, Front-End Performance & Rendering] assesses render efficiency and avoided
round-trips; this persist-and-restore dance is that concern made concrete. Around the base sit three
smaller helpers that are deliberately not members of it, so a page composing its own layout can
still use them: ListPageActions
(MMCA.Common.UI/Pages/Common/ListPageActions.cs:15) holds the mobile-versus-desktop reload dispatch
and the confirm-delete-reload flow every organizer list page repeats (:25-38, :56);
LatestLoadGuard (MMCA.Common.UI/Common/LatestLoadGuard.cs:38) gives each load
a generation and a token and cancels the previous one, which is what keeps a routed detail component
(reused by Blazor across route-parameter changes) from rendering entity 100's late answer after the
user has navigated to 101 (LatestLoadGuard.cs:50-59, :67); and
OfflineFirstPageSnapshot<TItem>
(MMCA.Common.UI/Pages/Common/OfflineFirstPageSnapshot.cs:29) records the first page of a list into
ILocalCacheStore as a private
CachedPage record (:39) and serves it back only when the device reports itself
offline, the store is available and the request is page 1, so the live path is untouched
(OfflineFirstPageSnapshot.cs:43, :49, :67). The single-aggregate counterpart of the list base
is DetailPageBase
(MMCA.Common.UI/Pages/Common/DetailPageBase.cs:26), an abstract ComponentBase that owns what
every detail page loaded by route id otherwise repeats: a page-scoped PageToken that cancels
in-flight work when the page goes away (:36), a LatestLoadGuard for route-driven reloads (:43),
and the inline edit lifecycle, where BeginEdit and EndEdit both reset IsDirty so the flag an
unsaved-changes guard reads can never be left set behind a closed editor (:46-49, :68-79,
rationale at :15-19). A derived page keeps only its load, its edit-model copy and its save, and
releases its own disposables in a Dispose(bool) override that calls the base last (:22-23,
:86-101). [Rubric §22, Responsive & Cross-Browser] is named by BreakpointConstants and exercised by
MobileInfiniteScrollList<TItem>
(MMCA.Common.UI/Components/Lists/MobileInfiniteScrollList.razor.cs:20), the mobile card list whose
IntersectionObserver sentinel, 500-item MaxRenderedItems cap (:52) and generation-guarded
supersession of in-flight fetches keep a long list bounded; a page that wants infinite scroll without
giving up its own card markup renders InfiniteScrollSentinel alone
(MMCA.Common.UI/Components/Lists/InfiniteScrollSentinel.razor.cs:21), which owns just the observer and is
disposed by the renderer the moment the host stops rendering it (:6-20).
State preservation across navigation. Paging, sort, filters and density live in the URL query
string as the source of truth, encoded and decoded by
ListPageQueryStateService under deliberately short reserved keys (p,
ps, mp, s, sd, d, q, f:<name>) with defaults omitted so a pristine list page has a clean
URL (MMCA.Common.UI/Services/ListPageQueryStateService.cs:15-40), so deep links and browser
back/forward replay correctly. The noisier scroll offset lives in
ListPageStateService
(MMCA.Common.UI/Services/ListPageStateService.cs:63), a per-circuit scoped service whose
synchronous dictionary is the fast path and whose HydrateFromSessionAsync
(ListPageStateService.cs:103) / PersistToSessionAsync (:138) mirror entries through
sessionStorage via a nav-interop.js module (:65) so state survives circuit teardown, forceLoad
navigation and the SSR to WASM transition. Every JS path there is defensively caught (prerender,
disconnected circuit, Safari private mode) so storage can never break the page. The immutable
ListPageState record (ListPageStateService.cs:9) carries page, page size, mobile
page, scroll, sort, density and a page-specific filter dictionary, and is updated with with
expressions. NavigationHistoryService
(MMCA.Common.UI/Services/Navigation/NavigationHistoryService.cs:12) bridges Blazor's
NavigationManager to the browser history API so a detail page can perform a real history.back()
when a previous entry exists and fall back to a fixed path otherwise. [Rubric §19, State Management & Data Flow] assesses a deliberate, scoped state model rather than ambient globals: these are
registered Scoped, so each circuit gets its own instance
(MMCA.Common.UI/DependencyInjection.cs:175-177). [Rubric §25, Navigation & Information Architecture] covers the route catalogue (RoutePaths
(MMCA.Common.UI/Common/RoutePaths.cs:7), NavItem with its role, claim, permission,
section and group facets plus resource-key titles resolved per circuit
(MMCA.Common.UI/Common/NavItem.cs:20), and the NavSection enum whose declaration
order is the sidebar order, MMCA.Common.UI/Common/NavSection.cs:7-17) and the open-redirect guard
ReturnUrlProtector, which accepts only same-origin relative paths beginning
with a single forward slash and rejects protocol-relative forms, backslashes, control characters and
anything that does not parse as a relative URI, replacing each with a fallback
(MMCA.Common.UI/Services/Navigation/ReturnUrlProtector.cs:18-60).
Authentication, the host-polymorphic token refresh, and the devices page. Client-side auth is
contracted by IAuthUIService
(MMCA.Common.UI/Services/Auth/IAuthUIService.cs:18), whose members are Result-returning like the
entity services, and implemented by AuthUIService
(MMCA.Common.UI/Services/Auth/AuthUIService.cs:44), which calls the WebAPI auth/* endpoints,
persists tokens through ITokenStorageService, pushes auth-state changes
through JwtAuthenticationStateProvider so AuthorizeView reacts
immediately, and coordinates push-registration through the device-capability contract
IPushRegistrationService
(AuthUIService.cs:49). Two named codes make its local-only failures legible rather than null:
Auth.TokenStorageUnavailable when the sign-in succeeded but JS interop could not persist the tokens,
and Auth.MissingAccessToken when a 2xx carried no usable token, which means the response shape
drifted (AuthUIService.cs:57, :63). The provider round trip is bound to the client that started
it: OAuthFlowStateStore
(MMCA.Common.UI/Services/Auth/OAuth/OAuthFlowStateStore.cs:20) mints a 32-character random hex value
before the challenge and persists it to device-local storage under auth.oauth-flow as a private
PendingAttempt record (:23, :53-56, :89), then refuses a completion whose
returned state does not match, or whose attempt is older than the 10-minute lifetime, removing the
attempt either way so one value is redeemable exactly once (:29, :68-87). The reason that matters
is stated on the type: the completion code is a bearer value, so without a binding an attacker can
complete the provider flow with their own account and hand the victim the completion link, signing the
victim's app in as the attacker (OAuthFlowStateStore.cs:8-16). A host with no durable storage at all
reports IsEnforced false and keeps the previous behavior, because nothing can be written across the
redirect (:39, :48-51, :70-73). Alongside login, register, OAuth exchange, logout, refresh and
change-password, it carries the self-service reset pair (IAuthUIService.cs:60, :67,
ADR-091) and the
multi-device session pair: GetSessionsAsync lists the caller's live refresh sessions newest first
and RevokeSessionAsync ends one of them (IAuthUIService.cs:75, :84,
ADR-097). Those two are
rendered by the framework-owned Sessions page
(MMCA.Common.UI/Pages/Auth/Sessions.razor.cs:27), reachable at /profile/sessions
(RoutePaths.cs:16), which deliberately offers two revoke paths: a per-row sign-out that ends one
other device's session, and a page-level sign-out-everywhere that also ends the caller's own and is
therefore followed by the local logout and a redirect. The current device's row carries no button at
all, since revoking it from here would leave the app signed in on a dead session until the access
token expired (Sessions.razor.cs:16-24, :110-112). The page-level path is
RevokeAllSessionsAsync, which signs out locally only once the server confirmed the revoke
(IAuthUIService.cs:94), and one busy flag disables every button while any revoke is in flight, so a
second click cannot race the list rebuild (Sessions.razor.cs:52-55). Each row is labelled from
UserAgentSummary (MMCA.Common.UI/Services/Auth/UserAgentSummary.cs:18), which
extracts a browser and a platform from the raw header with the most specific token winning (every
Chromium browser also says "Chrome", and Chrome and Edge both say "Safari",
UserAgentSummary.cs:20-38) and returns the two parts separately, because composing "Chrome on
Windows" in code would hard-code English word order (UserAgentSummary.cs:13-16).
Email confirmation
(ADR-116) ships the same
way, as a framework page over a framework client. IEmailConfirmationUIService
(MMCA.Common.UI/Services/Auth/IEmailConfirmationUIService.cs:21) is a separate interface rather than
two new IAuthUIService members, so a consumer's own IAuthUIService implementation keeps compiling
(MMCA.Common.UI/DependencyInjection.cs:164-166). Its two anonymous, Result-returning calls redeem a
link's single-use token and ask for a fresh link, the latter answering success on any well-formed
request because the endpoint returns 202 whether or not the address holds an account
(IEmailConfirmationUIService.cs:28, :30-38). The in-box
EmailConfirmationUIService
(MMCA.Common.UI/Services/Auth/EmailConfirmationUIService.cs:23) is deliberately not built on the
entity-service bases: the token is single-use and the resend spends a per-address request budget, so
a retried POST could only spend either one twice. It takes the "APIClient" straight from the factory,
posts exactly once and reads the answer through ProblemDetailsResultReader inside
HttpResultExecutor (EmailConfirmationUIService.cs:13-21, :35-48). The anonymous
ConfirmEmail page (MMCA.Common.UI/Pages/Auth/ConfirmEmail.razor.cs:36, routed at
/confirm-email, ConfirmEmail.razor:1) reads the email and token from the URI fragment, which
a browser never sends to a server, so the live token stays out of ingress logs, request telemetry
and Referer headers; the locationHashTake interop call also scrubs it from the address bar and
history, with query-string parameters honored as a fallback (ConfirmEmail.razor.cs:17-22, :139).
A link carrying both values is redeemed once on the first interactive render, a link missing either
lands on manual entry without posting a request that can only be refused, and a refused token keeps
the form with the API's own message and a resend button (:25-30, :108-128). What the page shows
is the private three-value ConfirmationState enum, Working, Form and
Confirmed (ConfirmEmail.razor.cs:50-60).
The refresh is the interesting part: one ITokenRefresher abstraction
(MMCA.Common.UI/Services/Auth/Tokens/ITokenRefresher.cs:17) has two implementations picked per host,
SameOriginProxyTokenRefresher for the browser (the refresh token
lives in an HttpOnly cookie and rotation happens server-side behind a same-origin
/auth/session/token proxy, so JS never sees it) and
DirectApiTokenRefresher for MAUI (the refresh token sits in OS
SecureStorage and is exchanged directly against auth/refresh). Storage is host-polymorphic in the
same way: WasmTokenStorageService holds the access token in memory only
and single-flights its re-acquisition behind a lock, since an unguarded ??= lets two callers each
start a hydrate and the later one overwrite the other's token
(MMCA.Common.UI/Services/Auth/Tokens/WasmTokenStorageService.cs:22, :22-38), while
ServerTokenStorageService reads the HttpOnly cookie through
CookieTokenReader whenever a live HttpContext exists (SSR
prerender) and switches to the in-memory token on the interactive circuit
(MMCA.Common.UI.Web/Services/ServerTokenStorageService.cs:30, :30-43,
ADR-022). The same AddCommonServerTokenStorage() also puts
BrowserOriginHandler
(MMCA.Common.UI.Web/Services/BrowserOriginHandler.cs:32) on the server-side "APIClient"
(MMCA.Common.UI.Web/DependencyInjection.cs:41, :47-48): a Blazor Server host calls the API on the
visitor's behalf, and without it every visitor would share the host's own address and send no
user-agent, so the registration rate limit and the session's recorded IP and device would key on the
host. It forwards X-Forwarded-For from the request's RemoteIpAddress, which has already been
through this host's forwarded-headers middleware rather than copied from whatever the client sent,
plus the browser's User-Agent, replacing any value already present and sending nothing when no
request is in scope (BrowserOriginHandler.cs:5-29, :47-52, :57-68). A third
contract, ISecureTokenStore
(MMCA.Common.UI/Services/Auth/Tokens/ISecureTokenStore.cs:16), is raw persistence with no freshness
semantics, and it exists to keep the MAUI graph acyclic: storage depends on the refresher, the
refresher depends on the raw store, and there is no loop (ISecureTokenStore.cs:5-14). A refresher can also say why it came back
empty. ITokenRefresher answers null both for "there is no session" and for a transient failure, so
the optional ISessionAwareTokenRefresher
(MMCA.Common.UI/Services/Auth/Tokens/ISessionAwareTokenRefresher.cs:12) adds
TryAcquireAccessTokenAsync, which returns a TokenAcquisition
(MMCA.Common.UI/Services/Auth/Tokens/TokenAcquisition.cs:9): NoSession for the endpoint's 401,
Unavailable for a 429 or 5xx, a dropped connection, missing JS interop or a cancelled call, and
Acquired(token) otherwise (ISessionAwareTokenRefresher.cs:17, TokenAcquisition.cs:3-8, :18,
:21, :35). Storage that remembers a "no session" answer for an anonymous grace period starts the
grace only on the definitive kind, so one 429 cannot sign a signed-in user out for the whole window,
and a refresher that does not implement the interface keeps the old reading of null as "no session"
(ISessionAwareTokenRefresher.cs:3-11). On
WebAssembly the first hydration (a JS interop hop for the session cookies plus a same-origin token
exchange) would otherwise run inline on whichever API call the user makes first, so a client host
may start TokenHydrationWarmup
(MMCA.Common.UI/Services/Auth/Tokens/TokenHydrationWarmup.cs:26) after builder.Build() and
discard the task, letting it overlap first render instead of delaying it (:11-15). It only reads
through ITokenStorageService.GetAccessTokenAsync, which never forces a refresh, so an anonymous
visitor gets a no-op, and it swallows and debug-logs every failure because the first real call
hydrates again with its own handling (TokenHydrationWarmup.cs:18-22, :36-57); the ADC client
wires it that way (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web.Client/Program.cs:92). The
ISessionCookieSync / JsFetchSessionCookieSync
pair mirrors the in-memory access token into the cookie by firing the fetch from the browser, so
the Set-Cookie lands in the user's own jar under both render modes, and both calls report
whether the jar was actually updated, false on a non-2xx answer or when JS interop is unavailable
(MMCA.Common.UI/Services/Auth/ISessionCookieSync.cs:8-19, JsFetchSessionCookieSync.cs:26-34). All three storage services agree
on one 30-second expiry skew read through JwtTokenInfo.IsFresh
(MMCA.Common.UI/Services/Auth/Tokens/JwtTokenInfo.cs:16-37, used at WasmTokenStorageService.cs:15,24 and
ServerTokenStorageService.cs:41), which parses the token client-side without validating its
signature, because the API validates every request. Every outbound call also passes
AuthDelegatingHandler, which attaches the stored bearer token to requests
that do not go through CreateAuthenticatedClientAsync
(MMCA.Common.UI/Services/Auth/AuthDelegatingHandler.cs:14), except one that sets its SkipBearer request
option: the token-refresh POST does, because that endpoint is anonymous and reading storage from
inside a refresh would re-enter the very acquisition waiting on it (AuthDelegatingHandler.cs:9-19). The lifecycle across render modes is
ADR-051; the cross-service
JWKS validation these tokens flow into is
ADR-004.
An opt-in same-origin API proxy: no token in the page at all. By default a browser client still
holds an access token in memory, the one /auth/session/token hands to script. A Blazor Web host can
remove even that by calling AddCommonSameOriginApiProxy(configuration) and
MapCommonSameOriginApiProxy() from MMCA.Common.UI.Web, a backend-for-frontend in which the browser
calls /api/** on the UI host's own origin and the host forwards to the gateway with the bearer taken
from the HttpOnly session cookie
(SameOriginApiProxyServiceExtensions,
MMCA.Common.UI.Web/SameOriginProxy/SameOriginApiProxyServiceExtensions.cs:12-18, :51;
SameOriginApiProxyEndpointExtensions,
MMCA.Common.UI.Web/SameOriginProxy/SameOriginApiProxyEndpointExtensions.cs:15, :33).
SameOriginApiProxySettings
(MMCA.Common.UI.Web/SameOriginProxy/SameOriginApiProxySettings.cs:21) binds SameOriginApiProxy
with a default for every value, so a host opts in with no section at all: a /api PathPrefix, a
GatewayAddress that falls back to Api:ApiEndpoint so a service-discovery name resolves exactly as
it does for the host's own clients, auth/login, auth/register and auth/oauth/exchange as the
token-issuing paths, auth/refresh and auth/revoke, and SameSite=Strict session cookies (:31,
:38, :46, :52, :59, :66, :74; the gateway fallback at
SameOriginApiProxyServiceExtensions.cs:57-64).
SameOriginApiProxySettingsValidator
(MMCA.Common.UI.Web/SameOriginProxy/SameOriginApiProxySettingsValidator.cs:14) runs on start and
refuses a prefix or path carrying route syntax, a query, a fragment or whitespace, a gateway that is
not an absolute address, and any SameSite other than Strict or Lax, because a cookie that
authenticates data calls must never ride on a cross-site request (:7-13, :16, :26-49). Opting in
also makes the session cookies claims-only toward the browser and has /client-config advertise the
proxy, which is what moves the WebAssembly "APIClient" and notification hub onto it
(SameOriginApiProxyServiceExtensions.cs:21-28, :69-74).
Each request runs through SameOriginApiProxyEndpoint
(MMCA.Common.UI.Web/SameOriginProxy/SameOriginApiProxyEndpoint.cs:29) in a fixed order (:15-28).
A foreign Origin or Sec-Fetch-Site is refused with 403 before anything else, and a WebSocket
upgrade must carry this host's own Origin, because an upgrade is not CORS-protected and the origin
is the only proof of who opened it (:115-131, :232-237); OPTIONS is answered locally with 204
and no CORS grant, so the gateway's CORS policy is never consulted on the proxy's behalf
(:241-245); and every unsafe method must carry the X-CSRF: 1 header named by
SameOriginProxyHeaders
(MMCA.Common.UI/Services/Auth/SameOriginProxyHeaders.cs:11, :14, :17), the one exemption being
an HTTP/2 WebSocket, to which a browser cannot add a header (SameOriginApiProxyEndpoint.cs:250-254).
The header is defense in depth behind the origin check and carries no secret
(SameOriginProxyHeaders.cs:3-9). The session step then validates or refreshes the cookie's access
token through the single-flighted ICookieSessionRefresher: a refused refresh token clears the
cookies and answers 401, while a refresh that could not be decided keeps them and answers 503 with
Retry-After, so a blip at the identity endpoint does not sign the user out (:22-25, :261-284).
A browser POST to the refresh path is answered by the proxy itself from the refresh cookie
(:351-356), and a safe method the gateway answers 401 gets one forced refresh and one replay, never
an unsafe one whose body the upstream could apply twice (:313-328). The per-request YARP transform
is SameOriginProxyTransformer
(MMCA.Common.UI.Web/SameOriginProxy/SameOriginProxyTransformer.cs:34): it maps {prefix}/rest to
{gateway}/rest, replaces whatever Authorization the browser sent with the session's bearer, and
keeps the session cookies and the CSRF header off the upstream request (:26-33, :55-62). Its
ProxyResponseMode (:14) picks the response treatment: Forward copies,
TokenIssuing moves a sign-in's token pair into the cookies and sends the browser the same JSON with
a claims-only accessToken and an empty refreshToken under no-store, and Revoke clears the
cookies whatever the upstream answered, so a failed revoke cannot strand the user in a session
(:16-23, :84-95, :100-145). Forwarding goes through
SameOriginProxyInvoker
(MMCA.Common.UI.Web/SameOriginProxy/SameOriginProxyInvoker.cs:19), one HttpMessageInvoker built
the way YARP requires rather than taken from IHttpClientFactory: no cookie container, which would
replay one user's upstream cookies on another user's request, no redirects or decompression, and none
of the host's default handlers, since a retry would replay a streamed body and the trusted-caller
header would exempt every browser request from the gateway's rate limiter (:9-18, :50-58). On the
client, SameOriginProxyRequestHandler
(MMCA.Common.UI/Services/Auth/SameOriginProxyRequestHandler.cs:11) is the innermost "APIClient"
handler: it strips any Authorization header, since the client only ever holds a claims-only token,
and stamps the CSRF header (:3-10, :18-20).
The Blazor Server circuit keeps calling the gateway server-to-server, but its tokens still have to
meet the browser's cookie jar, which it can reach only through a script fetch. The proxy therefore
replaces the circuit's ITokenRefresher and ISessionCookieSync with
HandoffTokenRefresher and
HandoffSessionCookieSync
(SameOriginApiProxyServiceExtensions.cs:83-84,
MMCA.Common.UI.Web/SameOriginProxy/HandoffSessionServices.cs:14, :39), which trade only
ciphertext with the page. SessionHandoffProtector
(MMCA.Common.UI.Web/SameOriginProxy/SessionHandoffProtector.cs:16) encrypts and signs with the
host's data-protection key ring under one purpose each way, so an access-token handoff can never be
replayed as a cookie write, and expires a handoff after one minute (:7-15, :19, :23-27); a
tampered, expired or wrong-purpose value unprotects to null (:57-72), and the cookie direction
serializes the pair as the private TokenPair record (:33-34, :75).
SessionHandoffEndpoints
(MMCA.Common.UI.Web/SameOriginProxy/SessionHandoffEndpoints.cs:15) maps the two halves,
POST /auth/session/handoff (validate-or-refresh from the cookies, answered with a protected access
token) and POST /auth/session-cookie/handoff (seed the cookies from a protected pair), both
requiring the CSRF header and both exchanging the HandoffBody { "handoff": "..." }
record (:17-18, :23-60, :64). Because a registration that lands later would silently put the
access token back in the page, MapCommonSameOriginApiProxy resolves both services and fails the boot
naming the culprit when either is not the handoff implementation, and it also fails when
AddCommonSameOriginApiProxy was never called, which it detects through the internal
SameOriginApiProxyMarker
(SameOriginApiProxyEndpointExtensions.cs:38-44, :59-92,
SameOriginApiProxyServiceExtensions.cs:91-92). [Rubric §26, Front-End Security] is the home
category: with the proxy on, no token is readable by script on the page.
Front-end security beyond tokens. [Rubric §26, Front-End Security] assesses token handling, XSS
exposure and secret storage, and this group answers it in five places: keeping the refresh token out
of JS-reachable storage (above); BlazorCspPolicyProvider, which pins
connect-src to 'self' plus the configured API/Gateway origin and its wss form for the SignalR
hub (MMCA.Common.UI.Web/Security/BlazorCspPolicyProvider.cs:28, :49-79) and, when the endpoint
cannot be parsed, fails closed: connect-src narrows to 'self' and the policy stays enforced,
so a misconfiguration surfaces immediately as blocked calls in the console rather than as a
Report-Only header that protects nothing and nobody notices (BlazorCspPolicyProvider.cs:16-20,
:60-62), feeding the shared
SecurityHeadersMiddleware through
ICspPolicyProvider;
WebApplicationExtensions.UseAuthenticatedNoStore, which emits
Cache-Control: no-store on authenticated HTML so a logged-out user pressing Back never sees the
previous user's page out of the bfcache while anonymous pages stay bfcache-eligible
(MMCA.Common.UI/Extensions/WebApplicationExtensions.cs:24-44); the returnUrl sanitizer already
covered; and, on a native head, the app-lock overlay BiometricGate
(MMCA.Common.UI/Components/Capabilities/BiometricGate.razor.cs:18), which re-arms once the app has
sat in the background longer than its 30-second ReLockAfter parameter, short by design because a
device handed to someone else is usually away for longer than a glance at a notification
(BiometricGate.razor.cs:19-25). It subscribes to the resume event regardless of the current
preference, since app lock can be switched on later in the same session and the handler re-reads the
preference every time it fires (:52-55), and it moves focus onto the unlock button once the locked
branch has actually rendered, so the panel is a real interaction boundary rather than a picture of one
(:31-45).
The CSP has exactly one host-configurable allowance, and it is configured rather than coded.
BlazorCspSettings
(MMCA.Common.UI.Web/Security/BlazorCspSettings.cs:18) binds the BlazorCsp section and carries a
single FrameSources list, the https origins this host's pages may embed in an iframe (:21,
:35); with the default empty list the provider emits no frame-src directive at all, so frames
fall back to default-src 'self' and the baseline policy stays byte-identical
(BlazorCspPolicyProvider.cs:85-90), and when the list is non-empty the origins are canonicalized to
scheme://host[:port] and de-duplicated ordinally before being joined into frame-src 'self' ...
(BlazorCspPolicyProvider.cs:92-96, BlazorCspSettingsValidator.cs:61-62). frame-ancestors 'none',
which governs who may frame this host, is never relaxed by any of it
(BlazorCspPolicyProvider.cs:113). Because each entry is spliced verbatim into a security response
header, the value is validated at startup rather than at first use:
BlazorCspSettingsValidator
(MMCA.Common.UI.Web/Security/BlazorCspSettingsValidator.cs:13) is registered with ValidateOnStart
and rejects anything that is not a plain https origin, refusing whitespace and the character set
*'";,@?# outright (:19, :42-48) and then requiring the https scheme, a non-empty host and a
root-only path (:52-54), with a failure message naming the offending entry so the boot log says
which one (:26-36). The reasoning is on the type: a quote, semicolon or space could smuggle a
keyword or a whole new directive into the policy, and a wildcard or bare scheme would widen
frame-src to arbitrary sites (BlazorCspSettingsValidator.cs:6-12). The options registration and
the validator are added by the same AddCommonBlazorCsp() that registers the provider, the validator
through TryAddEnumerable so calling the method twice does not run the validation twice
(MMCA.Common.UI.Web/DependencyInjection.cs:67-77).
The UI host's own edge: a request limiter. The Blazor Web host is an externally reachable origin
separate from the Gateway, so the edge limiter of
ADR-088 never sees a
request to it, while every page load there opens an interactive Server circuit in a container
typically sized at 0.25 vCPU and 0.5 GiB
(MMCA.Common.UI.Web/Hardening/UiRateLimitingSettings.cs:11-15). MMCA.Common.UI.Web therefore
ships a limiter of its own. UiRateLimitingSettings
(UiRateLimitingSettings.cs:33) binds the UiRateLimiting section with an Enabled switch that
defaults on, a per-IP PermitLimit of 300 over a 60-second WindowSeconds, and a replica-wide
GlobalConcurrencyLimit of 200 (:36, :43, :58, :62, :76). The 300 sits well above any single
visitor on purpose, because an office or carrier NAT presents many visitors as one address, and a
host whose audience shares one egress (a venue's wifi) is told to raise it (:49-55).
UiRateLimitingExtensions
(MMCA.Common.UI.Web/Hardening/UiRateLimitingExtensions.cs:25) turns that into a chained global
limiter, so a request must pass both a fixed window keyed by client IP and one concurrency bucket for
the whole process, each with a zero-length queue so excess is rejected with 429 at once rather than
queued (:101-108, :126-133, :163, :172-176). The settings are validated on start and then
closed over once rather than resolved per request, because the partition callback runs on every
request (:150-159). Exemptions are declared rather than inferred from middleware order: /health,
/alive, /_framework, /_content and /hubs match on whole segments, and any path whose last
segment carries a file extension counts as a static asset, while /_blazor has neither and stays
metered because its negotiate is exactly what opens a circuit (:42, :50-56, :62-70). An
unresolvable client IP fails open instead of collapsing every such request into one shared bucket
(:93-98), and both counts are per replica and in memory, the same trade the Gateway kit makes so the
limiter never puts a network round trip in front of the site (UiRateLimitingSettings.cs:27-30).
AddUiRateLimiting(configuration) registers it and UseUiRateLimiting() adds the middleware
(UiRateLimitingExtensions.cs:145, :187).
And a ceiling on open circuits. A rate still lets a caller who opens circuits slowly enough
accumulate them, and CircuitOptions only bounds circuits that have already disconnected, which is
what the second bound closes (MMCA.Common.UI.Web/Hardening/BlazorCircuitLimitSettings.cs:10-15).
BlazorCircuitLimitSettings (BlazorCircuitLimitSettings.cs:17)
binds BlazorCircuitLimits with a MaxActiveCircuits of 200 per replica, derived from that
container's memory and stated as an abuse ceiling rather than a capacity plan, plus a tighter
disconnected retention of 25 circuits for 60 seconds against the framework's 100 and three minutes
(:20, :27-38, :46, :57). BoundedCircuitHandler
(MMCA.Common.UI.Web/Hardening/BoundedCircuitHandler.cs:43) keeps the count as a CircuitHandler,
the documented extension point that sees a circuit open and close (:14-20). It increments first and
rolls back on refusal so two simultaneous opens cannot both take the last slot, floors the close-side
decrement at zero so an unpaired close never hands out permits forever, and runs last among the
registered handlers (:55, :64-73, :84-88). Refusal is a thrown InvalidOperationException, one
of the few places the Result pattern does not apply, because OnCircuitOpenedAsync returns a Task
with no refuse value; the client sees Blazor's reconnect UI rather than a crashed page (:23-28,
:70-72). BlazorCircuitLimitExtensions
(MMCA.Common.UI.Web/Hardening/BlazorCircuitLimitExtensions.cs:19) keeps the two halves as separate
calls because they attach to different builders: RetentionFrom(configuration) returns the
CircuitOptions callback for AddInteractiveServerComponents, and AddBoundedBlazorCircuits()
validates the options on start and registers the handler as a singleton, since circuit handlers
resolve from each circuit's own scope and a scoped one would count to one and cap nothing (:13-17,
:28-40, :51-60). Both read the same section, so the two numbers cannot drift apart. The ADC Blazor
Web host wires all four calls (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:72, :62,
:127, :197). [Rubric §12, Performance & Scalability] and [Rubric §29, Resilience]: a public
origin sheds load at its own edge instead of letting an unauthenticated loop exhaust its container.
Forms declare their rules once. The shared auth forms (LoginModel,
RegisterModel, ForgotPasswordModel,
ResetPasswordModel) are plain data-annotation EditForm models, with
PasswordComplexityAttribute mirroring the server's rule (at least 8
characters with upper, lower, digit and a non-alphanumeric character) so the form gives the verdict
the API would, and deferring empty input to [Required] so a blank field shows one message rather
than two (MMCA.Common.UI/Pages/Auth/PasswordComplexityAttribute.cs:15, :21-30).
OptionalEmailAttribute
(MMCA.Common.UI/Validation/OptionalEmailAttribute.cs:25) covers the optional address the stock
[EmailAddress] cannot express: blank passes, and anything else must hold exactly one @ that is
neither first nor last, the server's EmailRules check (OptionalEmailAttribute.cs:5-23, :38-41).
The auth models resolve their messages through the internal AuthFieldMessages
(MMCA.Common.UI/Resources/AuthFieldMessages.cs:19) as an ErrorMessageResourceType, reading
SharedResource in the current UI culture with the key itself as fallback, because MudBlazor also
runs a For field's attributes on its own and shows the message verbatim, so a bare resource key
there reached the screen raw (AuthFieldMessages.cs:11-18, :44).
AbsoluteUrlAttribute
(MMCA.Common.UI/Validation/AbsoluteUrlAttribute.cs:26) is the same parity argument with sharper
stakes: it requires an absolute http/https URL (:39-52) because these values are rendered
straight into an image source or a link target, so accepting javascript: or data: on the client
and rejecting it on the server would leave a round trip as the only thing between a pasted script URL
and the page (AbsoluteUrlAttribute.cs:5-11). Beyond the auth pages, MudBlazor fields bind their
Validation parameter to a delegate from ModelValidation.For
(MMCA.Common.UI/Validation/ModelValidation.cs:26, :43-49), which hands the model and the member
path MudBlazor supplies to an IModelValidator
(MMCA.Common.UI/Validation/IModelValidator.cs:13). That indirection is the extension point: the
in-box DataAnnotationsModelValidator
(MMCA.Common.UI/Validation/DataAnnotationsModelValidator.cs:21) runs the attributes on the model and
resolves every produced message as a resource key with pass-through, so a model can declare
ErrorMessage = "Validation.AbsoluteUrl" and a plain-English message still renders verbatim
(DataAnnotationsModelValidator.cs:13-19, :36-41); an EditForm gets the same localizing path by swapping
<DataAnnotationsValidator /> for
LocalizedDataAnnotationsValidator
(MMCA.Common.UI/Validation/LocalizedDataAnnotationsValidator.cs:21), which writes the results into
the form's EditContext, re-validating a field when it changes and the whole model on submit
(:9-20); a consumer that keeps its rules in
FluentValidation supplies its own implementation and MMCA.Common.UI never references a validation
library. That client-side parity is the point of [Rubric §24, Forms, Validation & UX Safety]: the
client predicts, the server decides.
Sign-in, registration and terms acceptance ship as framework pages. The /login page's
Login code-behind (MMCA.Common.UI/Pages/Auth/Login.razor.cs:8) reads the error
query value the OAuth completion endpoint sends a refused external sign-in back with and maps the
machine code to words, never showing the raw code, with a dedicated resource for each refusal that
carries its own recovery path and a generic refusal for the rest (:15-21, :23-29); when device
storage dropped the OAuth attempt it disables the web-redirect provider buttons, since a flow
started there would be refused at completion (:10-14). The /register page's
Register (MMCA.Common.UI/Pages/Auth/Register.razor.cs:10) validates any address the
user did type and blocks the registration on an invalid one instead of dropping it, while an
all-blank block sends no address (:15-40); the whole block is switchable through
RegistrationSettings.CollectAddress, bound from Registration and
defaulting to true so a host that configures nothing keeps the page as it was
(MMCA.Common.UI/Common/Settings/RegistrationSettings.cs:8, :11, :18). Legal documents are
LegalSettings (MMCA.Common.UI/Common/Settings/LegalSettings.cs:16), bound
from Legal: optional absolute URLs for the terms, the privacy policy, the code of conduct and the
account-deletion page, where an empty value renders nothing and a set TermsUrl is what makes the
register page require "I agree"; the current terms version itself stays server configuration
(LegalSettings.cs:5-15, :22, :26, :30, :34, :41). Consent owed after sign-up is
TermsAcceptanceGate
(MMCA.Common.UI/Components/Legal/TermsAcceptanceGate.razor.cs:29): it reads the signed-in user's
standing once per user from OnAfterRenderAsync, because the bearer token is readable only once the
renderer is interactive, renders nothing on any failed read so the gate never takes the app down with
the API, and otherwise holds a dialog with no close button, backdrop click, Escape or
close-on-navigation, since a list page rewriting its own URL right after load would otherwise dismiss
it for the rest of the session (:19-28, :31-45). Its client is
ILegalAcceptanceUIService
(MMCA.Common.UI/Services/Legal/ILegalAcceptanceUIService.cs:16), a Result-returning GetAsync and
AcceptAsync against Users/me/legal-acceptance that never throws for a server answer or a
transport fault, so a caller treats any failure as "do not block" (:6-15, :21, :27), implemented
by LegalAcceptanceUIService
(MMCA.Common.UI/Services/Legal/LegalAcceptanceUIService.cs:23) on the authenticated-service shape:
the read is retried, the accept POST is sent once (:14-20, :29-40; registered for every host at
MMCA.Common.UI/DependencyInjection.cs:168-170). On the profile page,
ChangePasswordCard
(MMCA.Common.UI/Components/Auth/ChangePasswordCard.razor.cs:18) validates all three fields
client-side first (the new password at least MinLength, 8 by default, and the confirmation equal to
it) and calls IAuthUIService.ChangePasswordAsync only for a valid form, toasting a success in its own
words and a refusal with the server's reason (:9-17, :33-38).
The component library is behind two facades. IToastService
(MMCA.Common.UI/Common/Interfaces/IToastService.cs:37) and
IAppDialogService
(MMCA.Common.UI/Common/Interfaces/IAppDialogService.cs:14) are the only way page code raises a
transient notification or asks a yes/no question. The toast contract carries the four named severities
plus a runtime-severity Show, a two-line ShowPersistent that stays until dismissed (the
push-notification shape: a message that arrived unprompted must not expire before the user looks at
the screen, IToastService.cs:64-72) and a ShowAction that renders a button for the undo/view/retry
case, with the explicit warning that the callback runs outside any render callback so a caller whose
work can fail must guard it (IToastService.cs:74-101); severity itself is the framework's own
ToastSeverity enum (IToastService.cs:8).
MudToastService (MMCA.Common.UI/Services/MudToastService.cs:19) and
MudAppDialogService (MMCA.Common.UI/Services/MudAppDialogService.cs:11)
are the only two types in the framework that name MudBlazor's ISnackbar and IDialogService,
and even the severity projection is written out as a switch rather than cast, because the two enums
agreeing numerically today is not a dependency worth taking silently
(MudToastService.cs:92-104). Announcement is deliberately not this service's job: the shared
provider block hosts MudBlazor's snackbar inside a role="status" aria-live="polite" element, so the
rendered toast already is the live-region content and a second channel would read the same sentence
twice (MudToastService.cs:12-17). The dialog facade collapses a dismissal (backdrop click, escape) onto
false, so a caller only ever branches on true (MudAppDialogService.cs:14-26). Both are
registered by their own AddCommonUiFacades() (MMCA.Common.UI/DependencyInjection.cs:228-233),
separate from AddUIShared so a bUnit harness can resolve exactly these two without the rest of the
shared-UI surface. [Rubric §1, SOLID] (dependency inversion) and [Rubric §14, Testability]: a
component test records toasts instead of driving a rendered snackbar host.
Design system and theming. Visual consistency is centralized in one static
MMCATheme MudTheme instance (MMCA.Common.UI/Theme/MMCATheme.cs:9, :11) holding a
light palette (:13-55), a full dark palette (:56-111), an Inter-first typography scale (:112-190)
and a 6 px default border radius (:191-194). It is applied through the shared MmcaThemeProviders
component, which renders the four Mud providers every root layout needs exactly once and takes the
theme as a parameter defaulting to MMCATheme.Instance, so an app with its own brand passes a derived
MudTheme instead of duplicating the provider block
(MMCA.Common.UI/Theme/MmcaThemeProviders.razor:33-36). The palette itself comes from a
single C# source of truth, BrandColors (MMCA.Common.UI/Theme/BrandColors.cs:10),
whose doc comment states the duplication contract plainly: the CSS custom properties in
wwwroot/app.css must mirror these constants because C# cannot read CSS at build time, and
BrandColorTokenTests asserts the two stay in sync (BrandColors.cs:3-9). Color choices carry
explicit WCAG reasoning: Secondary is Teal 700 #00796B for about 5.3:1 on light surfaces because the
Teal 600 it replaced sat at about 4.0:1, under the AA 4.5:1 floor (BrandColors.cs:21-26), and
the light Warning is Amber 900 #A85D00 (4.96:1 on Surface, 4.79:1 on Background) rather than the
Material amber #F57F17, which measures only about 2.65:1 on white and so fails the 4.5:1 floor
everywhere the palette colour is used as text or as a border rather than as a fill, with
WarningContrastText moved to white in the same edit because the two values have to move together
(MMCATheme.cs:28-36). The dark palette makes the mirror-image choice, keeping rgba(0,0,0,0.87) on
#FFA726, where white would be about 2.0:1 and dark text is about 10.8:1 (MMCATheme.cs:83-85).
[Rubric §20, Design System, Theming & Consistency] is the home category (one token source, dark
mode, consistent typography) and [Rubric §21, Accessibility] is woven into the palette itself and
into the chrome, down to the skip-to-content link the shared layout renders first
(MMCA.Common.UI/Layout/MainLayout.razor:17).
Dark mode is a service, not a flag. ThemeService
(MMCA.Common.UI/Theme/ThemeService.cs:17, registered Scoped at DependencyInjection.cs:180)
owns the preference: InitializeAsync reads the stored value through a theme.js module and falls
back to the OS prefers-color-scheme only when nothing is stored (ThemeService.cs:35),
SetDarkModeAsync persists through the same module and raises OnChange (ThemeService.cs:29,
:54), and the JS module handle is held by LazyJsModule
(MMCA.Common.UI/Services/LazyJsModule.cs:20), a single-flight importer that caches the in-flight
import under a lock so two concurrent callers cannot leak a second module reference, and that drops a
failed task so an import attempted during prerender does not poison the module for the rest of the
circuit (LazyJsModule.cs:5-19, :22-25). MmcaThemeProviders subscribes to OnChange and
re-renders (MMCA.Common.UI/Theme/MmcaThemeProviders.razor:52), and every best-effort theme interop
call it makes goes through the internal ThemeInterop.TryAsync
(MMCA.Common.UI/Theme/ThemeInterop.cs:9, :19-30), which turns a JSException, a
JSDisconnectedException or an InvalidOperationException (a prerender race, a disposed dispatcher)
into false, so a JS failure never escapes a component lifecycle and kills the circuit
(MmcaThemeProviders.razor:62, :90). Honest caveat: on a web head the no-flash SSR bootstrap
is not wired for theme. InitializeAsync runs from OnAfterRenderAsync(firstRender) because JS
interop is unavailable during prerender (MmcaThemeProviders.razor:55-62), so the bound mode is
corrected just after hydration and a brief wrong-theme first paint is possible
(ADR-028). A head that already knows
the stored preference without JS closes that gap by registering an
IInitialThemeModeSource
(MMCA.Common.UI/Theme/IInitialThemeModeSource.cs:14), whose synchronous IsDarkMode (null when
unknown) MmcaThemeProviders reads during initialization, with the JS path still running afterwards
and staying authoritative; a MAUI hybrid head reading device preferences is the intended implementer,
and web heads register nothing (IInitialThemeModeSource.cs:3-13, :21,
MmcaThemeProviders.razor:44-47).
Internationalization: one culture decision, carried everywhere. The framework serves en-US and
Spanish (es) plus a development-only pseudo locale, and the hard part is not the translations, it is
making one culture decision agree across the InteractiveAuto split (SSR prerender, then an
InteractiveServer circuit, then an InteractiveWebAssembly client) and across the cross-origin REST
services behind the Gateway, with no language flash and no hydration mismatch
(ADR-027, which supersedes the
single-locale stance of ADR-011). A
single non-HttpOnly culture cookie is the source of truth. The WASM client reads it at startup through
MmcaCultureBootstrap.SetBrowserCultureAsync, which assigns
CultureInfo.DefaultThreadCurrent[UI]Culture before RunAsync() and falls back to
SupportedCultures.Default
(MMCA.Common.UI/Services/Culture/MmcaCultureBootstrap.cs:22-34). Outbound API calls forward the active
culture as an Accept-Language header through
CultureDelegatingHandler
(MMCA.Common.UI/Services/Culture/CultureDelegatingHandler.cs:13, :20-25), wired into the "APIClient"
pipeline at DependencyInjection.cs:146-147, because the cross-origin Gateway does not carry the
cookie through to the services and that header is what makes a backend failure come back localized.
View strings are externalized to co-located .resx resolved by IStringLocalizer<T>
(AddLocalization() at DependencyInjection.cs:88), anchored by two marker types:
SharedResource for cross-cutting chrome
(MMCA.Common.UI/Resources/SharedResource.cs:9) and MudTranslations for
MudBlazor's own component text (pager, filter menus, pickers,
MMCA.Common.UI/Resources/MudTranslations.cs:10), served through
ResxMudLocalizer, which AddUIShared TryAdds because AddMudServices
registers no MudLocalizer of its own (DependencyInjection.cs:97-101) and whose values degrade to
MudBlazor's built-in English when a key reports ResourceNotFound
(MMCA.Common.UI/Globalization/ResxMudLocalizer.cs:7-19). How MudBlazor reaches that fallback is
replaced too: AddUIShared installs
InvariantMudLocalizationInterceptor
(MMCA.Common.UI/Globalization/InvariantMudLocalizationInterceptor.cs:37) through MudBlazor's
AddLocalizationInterceptor, which replaces the default regardless of whether AddMudServices ran
first (MMCA.Common.UI/DependencyInjection.cs:110). The resolution order is unchanged (an English UI
culture, or no MudLocalizer, reads the built-in strings; any other culture asks the MudLocalizer
and falls back on ResourceNotFound, InvariantMudLocalizationInterceptor.cs:49-60), and the
English test is the parent language, so en-US counts as English while the neutral en goes
through the localizer like every other culture (:46-52). The one difference is how the built-in
strings are read. MudBlazor 9.7+ assigns CultureInfo.CurrentUICulture to the invariant culture and
then assigns the previous value back, and both writes land in an AsyncLocal, so the "restore"
leaves the calling thread's ExecutionContext carrying an explicit culture from then on; on a MAUI
hybrid head the renderer dispatches on the process's main thread, which nothing ever resets, so the
first MudBlazor chrome string a page renders pins the app to its launch language and a later switch
loses to the pinned value even after a WebView reload (:16-27). The private nested
BuiltInStrings (InvariantMudLocalizationInterceptor.cs:73) reads the same
embedded MudBlazor.Resources.LanguageResource through a ResourceManager with an explicit
invariant culture instead, addressing it by manifest name because MudBlazor's generated resource
class is internal (:75, :77, :89, :98-102), which is what the culture swap was for (no
satellite-assembly probing under a non-English culture) with no culture assignment at all.
Count-sensitive messages are handled by a suffix convention rather than by string concatenation:
StringLocalizerPluralExtensions.Plural
(MMCA.Common.UI/Globalization/StringLocalizerPluralExtensions.cs:20, :40) asks for {key}.One
when the count is exactly one and {key}.Other otherwise, falling back to the base key when the
plural sibling is missing so a resource set that has not been split yet keeps rendering its single
message instead of leaking a raw key name to the reader (:46-51); the send confirmation is the
current call site (MMCA.Common.UI/Pages/Notifications/NotificationSend.razor.cs:137). Two
categories cover both shipped cultures, and a language with more CLDR categories needs a category
selector rather than more suffixes, which would not change the call site
(StringLocalizerPluralExtensions.cs:13-19). Applying a switch is host-specific and sits
behind ICultureApplier: the web default
EndpointCultureApplier force-loads the server /culture/set endpoint so
the server re-renders SSR under the new cookie and the WASM runtime re-reads it on startup
(MMCA.Common.UI/Services/Culture/EndpointCultureApplier.cs:18-32), while a MAUI hybrid head, having no
ASP.NET pipeline, replaces it after AddUIShared with an in-process applier
(MauiCultureApplier, chapter 26). The
development-only pseudo locale is the group's own i18n test harness:
PseudoStringLocalizerFactory decorates IStringLocalizerFactory
unconditionally (DependencyInjection.cs:95,
MMCA.Common.UI/Globalization/PseudoStringLocalizerFactory.cs:11) so every IStringLocalizer in the
host is wrapped in a PseudoStringLocalizer at once, and
PseudoLocalizer accents every letter, pads the text and wraps the result in a
bracket sentinel while leaving { } placeholders verbatim
(MMCA.Common.UI/Globalization/PseudoLocalizer.cs:20-30), which makes hard-coded strings,
fixed-width layouts and concatenated fragments all visible in one pass (PseudoLocalizer.cs:12-19).
Even the snackbar text is localized: ErrorMessages keeps its static call sites but
resolves each message from SharedResource once the root layout hands it a localizer, falling back to
the English format string until then (MMCA.Common.UI/Pages/Common/ErrorMessages.cs:24, :33), and
it never renders an exception's own Message, because raw exception text is neither localizable nor
safe to surface (ErrorMessages.cs:14-22). Time is shown on the viewer's clock rather than the
server's: ViewerTimeZone
(MMCA.Common.UI/Services/Culture/ViewerTimeZone.cs:18, registered scoped at
MMCA.Common.UI/DependencyInjection.cs:190) reads the browser's IANA zone once per scope through
time-zone.js, resolves it with TimeZoneInfo, and converts to UTC until then or whenever the zone
cannot be read, never to the server's own zone, which on Blazor Server and during prerender is the
wrong answer for every viewer elsewhere (ViewerTimeZone.cs:6-17, :32). A page calls
EnsureResolvedAsync from its first OnAfterRenderAsync and re-renders when it returns true;
during prerender the call is skipped and a later one retries (:49, :62-69). [Rubric §27, Internationalization] is the home category
here, and adding a locale is a .es.resx sibling plus one allowlist entry, not new infrastructure.
Per-user preference persistence. A signed-in user's culture and theme follow them across devices
via the Identity profile. IUserPreferenceWriter /
ApiUserPreferenceWriter PUT to auth/preferences over the shared
"APIClient" (MMCA.Common.UI/Services/Preferences/ApiUserPreferenceWriter.cs:22, :62-66) using the private
UserPreferencesRequest record (:29), and
IUserPreferenceReader /
ApiUserPreferenceReader GET the same endpoint at login and return the
immutable UserPreferences record, whose null fields mean "leave unchanged"
(MMCA.Common.UI/Services/Preferences/UserPreferences.cs:9,
MMCA.Common.UI/Services/Preferences/ApiUserPreferenceReader.cs:14, :21-31). The write is strictly
best-effort: the cookie is the device-local runtime channel and a failed persist never breaks the
in-page switch. Best-effort has a cost, though, and both sides guard it, first by refusing to send
when the token is missing, unreadable or within 30 seconds of expiry via JwtTokenInfo.IsFresh
(ApiUserPreferenceWriter.cs:27, :47; ApiUserPreferenceReader.cs:21, :31), and second by
remembering the exact token the API last answered 401 to, so a revoked session costs one failed
request rather than one per toggle (ApiUserPreferenceWriter.cs:31-37, :55-58, :68-71).
Comparing the token rather than setting a latch is what lets a fresh sign-in resume writing with no
reset step. That is a [Rubric §13, Observability & Operability] detail as much as a [Rubric §19, State Management] one: at low traffic, one 401 per theme toggle is enough on its own to trip a
failed-request alert rule.
Pluggable UI modules. The module system that organizes the back end
(IModule, chapter 14) has a front-end counterpart in
IUIModule (MMCA.Common.UI/Common/Interfaces/IUIModule.cs:11). A module descriptor
exposes its navigation entries as NavItem values, the Assembly holding its Razor pages
so the host can add it to AdditionalAssemblies for route discovery, and two defaulted collections of
component types to render in the app bar and at the root layout (IUIModule.cs:12-22). The
registration prologue is shared too: AddUIModule<TModule>() runs one Scrutor scan that picks up
every IEntityService<,> implementation in the module's assembly as scoped, then registers the
descriptor as a singleton (MMCA.Common.UI/DependencyInjection.cs:320-329), so a module's own
Add{Module}UI() no longer carries its own copy of that scan and can still register services that
must win afterwards. UIModuleConfiguration lets a host switch a module off
through Modules:{name}:Enabled, defaulting to enabled when the section is absent
(MMCA.Common.UI/Common/Settings/UIModuleConfiguration.cs:18-22), and
IHomePageContent is the per-app landing-page hook behind the shared / route,
naming the component type and the page title
(MMCA.Common.UI/Common/Interfaces/IHomePageContent.cs:8-15). Adding a feature module therefore wires
its pages, its services and its menu entries into the shell with no edit to the shell.
[Rubric §18, UI Architecture] and [Rubric §1, SOLID] (open/closed).
A complete vertical slice shipped inside the framework: notifications. Unlike the rest of the
package, which is base classes consumers extend, the Notifications area is a finished feature an app
switches on with one call. NotificationUIModule
(MMCA.Common.UI/Notifications/NotificationUIModule.cs:15) contributes a user-facing inbox nav entry
plus a push-notification entry gated on the NotificationPermissions.Manage permission rather than on
a role name (:17-21, the permission facet at :20), the app-bar
NotificationBell (:23) and a root-layout listener component (:25);
NotificationInbox, NotificationList and
NotificationSend (with its NotificationSendModel
form model) render it; NotificationInboxService and
PushNotificationService (behind
INotificationInboxUIService and
IPushNotificationUIService) call the API; and
NotificationHubService
(MMCA.Common.UI/Services/Notifications/NotificationHubService.cs:44) holds the SignalR
connection to the API's NotificationHub. A dropped
connection reconnects for as long as the service is alive: the internal
UnboundedReconnectPolicy
(MMCA.Common.UI/Services/Notifications/UnboundedReconnectPolicy.cs:21, wired at
NotificationHubService.cs:206) walks a 0, 2, 5 and 10 second warm-up and then retries every 30
seconds, where the SignalR default gives up after about 42 seconds and leaves live notifications dead
until a page reload (UnboundedReconnectPolicy.cs:6-20, :24-32, :44-46). A connection that still
closes with an error is restarted under the same 30-second cap, a deliberate stop or dispose is never
restarted, and a 401 or 403 handshake ends both loops, since an expired session cannot recover by
retrying (NotificationHubService.cs:32-41, :280). The same connection
carries ephemeral live channel events
(ADR-039): components join through
JoinChannelAsync (NotificationHubService.cs:319), membership is reference-counted per key by
ChannelReferenceCounter
(MMCA.Common.UI/Services/Notifications/ChannelReferenceCounter.cs:16) so one subscriber leaving does
not cut the channel off for the others, handlers are multicast through disposable
ChannelSubscription handles (NotificationHubService.cs:771), and every held
channel is re-joined on Reconnected because SignalR group membership does not survive a new
connection (NotificationHubService.cs:226). On a WebAssembly client whose host runs the same-origin
proxy, the hub connects through it and sends the proxy's CSRF header instead of an access token
(NotificationHubService.cs:100-109, :540). Which notifications a user sees can be narrowed by
INotificationScopeProvider, an app-supplied scope key such as
"event:2" that both HTTP services consume so a send and the reads that follow agree, defaulting to
the unscoped NullNotificationScopeProvider and contractually
forbidden from throwing, with the further instruction to fail closed (return the last known key)
rather than degrade to null, since a null silently widens the view to every notification
(MMCA.Common.UI/Services/Notifications/INotificationScopeProvider.cs:9-22). Shared unread state
lives in NotificationState
(MMCA.Common.UI/Services/Notifications/NotificationState.cs:18), which stamps when the count was
last established so a subscriber can ask IsStale instead of re-fetching on every trigger (:7-12)
and arbitrates a single active-poller slot by owner reference rather than a counter, so a teardown
that never unregisters cannot strand the slot for the life of the circuit (:23-29). Both of the
badge's timings are configuration rather than compiled-in constants:
NotificationBellOptions
(MMCA.Common.UI/Common/Settings/NotificationBellOptions.cs:12) binds a 30-second default
PollInterval and a 30-second NavigationRefreshMaxAge, so a deployment paying per API call widens
the poll and a page change within the window keeps the count it has (:22, :29), read by the bell
through IOptions and a TimeProvider
(MMCA.Common.UI/Components/Notifications/NotificationBell.razor.cs:30, :36-37). The bell also
registers strictly symmetrically, because hosts render it twice inside <AuthorizeView> and a routine
token refresh tears both instances down and rebuilds them (NotificationBell.razor.cs:22-29). The
whole feature is wired by its own AddNotificationUI()
(MMCA.Common.UI/Notifications/DependencyInjection.cs:12, :20-42), kept separate so an app that does
not want real-time notifications never pays for the SignalR plumbing. A host that skips it can also set
HideNotificationPagesWhenUnregistered (MMCA.Common.UI/Common/Settings/LayoutSettings.cs:49), and
NotificationPageGate
(MMCA.Common.UI/Notifications/NotificationPageGate.cs:12) then has the router answer the three
notification pages with the not-found page whenever no NotificationUIModule is registered
(:14-31). The router places the internal
NotificationPageNotFoundStatus
(MMCA.Common.UI/Notifications/NotificationPageNotFoundStatus.cs:11) beside that not-found page; it
renders nothing and calls NavigationManager.NotFound() on initialization, so a server render
answers 404 rather than 200 (:5-10, :18). The pages themselves declare the
mmca:notification-pages policy instead of a bare [Authorize], because [Authorize] is endpoint
metadata and the authorization middleware would challenge a signed-out visitor to sign in before the
router's gate ever ran, for a page that does not exist on this host
(NotificationPageRequirement,
MMCA.Common.UI/Notifications/NotificationPageRequirement.cs:20, :23, rationale at :14-19).
NotificationPageAuthorizationHandler
(MMCA.Common.UI/Notifications/NotificationPageAuthorizationHandler.cs:14) passes an authenticated
caller, and every caller while the host hides the pages, reusing the gate's own predicate so the two
cannot disagree (:26-30); AddUIShared registers the policy and the handler for every host
(MMCA.Common.UI/DependencyInjection.cs:78-85).
Administration screens ship as components, not pages. Identity administration is the second
finished feature in the package
(ADR-116), and it is
delivered as components an app routes itself: UserAdminList<TUser>
(MMCA.Common.UI/Pages/Administration/UserAdminList.razor.cs:47), RoleAdminList
(MMCA.Common.UI/Pages/Administration/RoleAdminList.razor.cs:28) and
RoleAdminEdit (MMCA.Common.UI/Pages/Administration/RoleAdminEdit.razor.cs:36)
carry no @page directive, so the route, the authorization attribute and the links between them stay
the app's decision (UserAdminList.razor.cs:17-22, RoleAdminList.razor.cs:12-14,
RoleAdminEdit.razor.cs:13-14). The account roster is the one shipped screen that derives from the
list-page base itself (MMCA.Common.UI/Pages/Administration/UserAdminList.razor:4) and it is generic
over the app's own DTO, constrained only to IUserAdminDTO (UserAdminList.razor.cs:47-48). It has
two data paths: by default it reads through
IUserAdminUIService<TUserDto>
(MMCA.Common.UI/Services/Administration/IUserAdminUIService.cs:18), whose endpoint takes a search
term and a role rather than the grid's per-column filter syntax
(IUserAdminUIService.cs:11-16, :27-32), while an app whose roster lives on its own filtered
endpoint passes a FetchPage delegate instead and the service is never resolved for reads, with the
free-text box injected under the SearchFilterKey filter key (UserAdminList.razor.cs:32-39,
:50-55). The three account actions always go through
IUserAdminActionsUIService
(MMCA.Common.UI/Services/Administration/IUserAdminActionsUIService.cs:16), the non-generic half so a
component that only acts on an account never names the DTO, and they are hidden on the signed-in
operator's own row: an operator who locked or demoted themselves would lose the very capability needed
to undo it, and the API has no notion of "the caller" (UserAdminList.razor.cs:40-44). SetRoleAsync
is its own member rather than a default interface implementation over SetRolesAsync, because a
default implementation is not virtual on the interface a mock proxies, so a test could neither stub
nor verify it (IUserAdminActionsUIService.cs:40-54).
Role administration is non-generic throughout, since a role and a permission are both strings the
framework already owns (IRoleAdminUIService,
MMCA.Common.UI/Services/Administration/IRoleAdminUIService.cs:22, rationale at :11-15). The roster
reports the permissions a role's code grants and the permissions stored rows grant as two separate
counts, because only the stored half is editable and a single total would hide that removing a
compiled permission is a code change (RoleAdminList.razor.cs:16-20). The editor offers only what the
server's catalog declares, so nothing it can submit is a permission no endpoint checks, and it locks
two kinds of checkbox: one the host compiled in for this role, and ManageRoles itself, which guards
this very screen and which the server refuses to store, so offering it would be offering an action
guaranteed to fail (RoleAdminEdit.razor.cs:17-29). The rest are grouped by the area before the first
colon of an area:capability name through the private PermissionGroup record,
with un-prefixed permissions filed under a localized "General" (RoleAdminEdit.razor.cs:37-38,
:222, :301-303). Wording stays the app's: every string these three render is looked up in an app-supplied
IStringLocalizer first and falls back to the framework's
UserAdminListResources,
RoleAdminListResources and
RoleAdminEditResources markers, so "Deactivate" instead of "Lock" costs
no parameter per word (UserAdminList.razor.cs:24-31,
MMCA.Common.UI/Pages/Administration/RoleAdminListResources.cs:9-14). Both implementations,
UserAdminService<TUserDto>
(MMCA.Common.UI/Services/Administration/UserAdminService.cs:26) and
RoleAdminService
(MMCA.Common.UI/Services/Administration/RoleAdminService.cs:25), derive from
AuthenticatedServiceBase rather than the entity-service base, because
these endpoints are not CRUD over an entity resource, and they read every answer back through
ProblemDetailsResultReader inside
HttpResultExecutor so only the caller's own cancellation still propagates
(UserAdminService.cs:15-22, RoleAdminService.cs:64-78, :89-101). [Rubric §18, UI Architecture & Component Design] again: the framework ships the screen, the app keeps the route, the vocabulary and
the DTO.
How it wires up at startup. A host's Program.cs calls AddUIShared(configuration) once, a C#
extension(IServiceCollection) member (see
primer §4) on
DependencyInjection (MMCA.Common.UI/DependencyInjection.cs:31, :39-209).
In order it binds and validates on start ApiSettings, so a missing endpoint
fails the host rather than the first request (:42-45; the read-only face of those options is
IApiSettings, whose WasmApiEndpoint lets the server call an internal URL while
the browser is handed an external one, MMCA.Common.UI/Common/Settings/IApiSettings.cs:11-17); binds
LayoutSettings, LegalSettings, RegistrationSettings, UiReadCacheOptions and
NotificationBellOptions without validation, deliberately optional so a host that configures none
of them keeps the compiled-in defaults (:48-67); TryAdds TimeProvider.System as the clock those
staleness policies are measured against and the read cache itself (:71, :76); registers the
notification pages' authorization policy and its handler (:81-85); sets up localization, the
pseudo/Mud localizer decorators and the MudBlazor localization interceptor (:88-110); registers the
auth and culture delegating handlers and the named
"APIClient" whose base address comes from ApiSettings and whose timeout is pinned to
HttpResilienceDefaults.TotalRequestTimeout
rather than the BCL's arbitrary 100s, so the transport never pre-empts the resilience budget
(:118-147; when Api:SameOriginApiEndpoint is configured the base address is that proxy endpoint
and SameOriginProxyRequestHandler joins as the innermost handler,
:120-121, :136, :149-154); calls AddCommonUiFacades() for the toast and dialog pair (:159);
then TryAdds AuthUIService, the email-confirmation client (:166), the
terms-acceptance client (:170), the OAuthFlowStateStore (:174),
the two list-page state services,
NavigationHistoryService, ThemeService,
EndpointCultureApplier, ViewerTimeZone (:190),
NavigationPublicLinkBuilder behind
IPublicLinkBuilder (share-sheet and QR links resolved against the browser
origin, which a MAUI head replaces because its WebView origin is a virtual host nobody else can open,
MMCA.Common.UI/Services/Navigation/IPublicLinkBuilder.cs:9,
MMCA.Common.UI/Services/Navigation/NavigationPublicLinkBuilder.cs:11), the preference reader and writer, and a
default IOAuthUISettings (DefaultOAuthUISettings)
that downstream apps override with
ConfigurationOAuthUISettings, which reads provider availability
from the OAuth section for a server host and from pre-computed Enabled flags for a WASM client
(:161-204, MMCA.Common.UI/Services/Auth/OAuth/ConfigurationOAuthUISettings.cs:13, :24-30); and finally
calls AddDeviceCapabilityDefaults() so every capability contract resolves on every head (:208,
ADR-042, chapter 26).
The TryAdd* discipline is what lets a consumer pre-register its own implementation and win. Browser
hosts add AddClientAuthSessionCookieSync() (:240-242) and AddWasmFormFactor() (:252); a
Blazor Server head adds AddCommonServerTokenStorage(), AddCommonBlazorCsp() (before
AddCommonSecurityHeaders, so it beats the TryAdded static provider) and
AddCommonWebFormFactor() from MMCA.Common.UI.Web
(MMCA.Common.UI.Web/DependencyInjection.cs:41, :67, :85; the first also registers BrowserOriginHandler, :47-48) plus the
UseAuthenticatedNoStore() middleware. The administration screens are opt-in on the same pattern:
AddUserAdministrationUI<TUserDto>() registers the generic service and forwards the non-generic
actions contract to that same instance, so the roster and the actions it performs go through one
object and one substitute in a test (MMCA.Common.UI/DependencyInjection.cs:269-276), and
AddRoleAdministrationUI() registers the role client (:295-297); nothing in the framework calls
either, so an app serving no administration endpoints registers nothing and the components are
simply never rendered. An
SSR host behind the gateway also calls AddTrustedCallerHeader(configuration), which composes
TrustedCallerHandler
(MMCA.Common.UI.Web/Security/TrustedCallerHandler.cs:35) onto every HttpClient the host creates,
inserted at index 0 so the origin gate judges the authority the caller wrote rather than one Aspire's
service-discovery handler has already rewritten (MMCA.Common.UI.Web/DependencyInjection.cs:121,
:143-156), and registers nothing at all when no secret, header name or parseable gateway origin is
configured (the early return at :137). The breadth is the point: the call that suffers most from the gateway's per-IP
limiter partition, the cookie-session token refresh, is created by the framework under a name a host
cannot reach, while the handler itself stays narrow, stamping the secret only on a request whose
scheme, host and port match the gateway origin and replacing rather than appending the header, because
the gateway compares one single-valued header in constant time (TrustedCallerHandler.cs:9-27,
:67-79). UISharedAssemblyReference
(MMCA.Common.UI/DependencyInjection.cs:335) is the marker other assemblies scan against.
How a WebAssembly client learns its API address. The WASM bundle is static, so the API base
address is fetched at runtime rather than baked in. On the server side,
ClientConfigEndpointExtensions
(MMCA.Common.UI.Web/ClientConfig/ClientConfigEndpointExtensions.cs:17) adds
MapClientConfigEndpoint, an anonymous GET /client-config excluded from the OpenAPI description
whose framework-owned Api section carries Api:WasmApiEndpoint (:17, :52-81). It
fails closed: with that key missing it throws rather than fall back to Api:ApiEndpoint, which is
the server head's service-discovery name a browser cannot resolve, so the client fails to start
instead of pointing at the wrong API (:32-38, :53-59). When the host runs the same-origin proxy,
the same api section also carries sameOriginApiEndpoint, the proxy prefix with a trailing slash,
which is what moves the client's "APIClient" onto it (:84-99). The endpoint declares AllowAnonymous
itself, because the client fetches the document before it can hold a session and a host's fallback
policy would otherwise gate it (:40-44, :80). A host adds its own sections through a per-request
ClientConfigBuilder
(MMCA.Common.UI.Web/ClientConfig/ClientConfigBuilder.cs:17), whose Add refuses a blank name, the
reserved Api section and a duplicate, and whose remarks warn that everything added is served to
every browser before sign-in, so only public values belong there (:12-16, :45-62). The client
half is MmcaClientConfigBootstrap
(MMCA.Common.UI/Common/Settings/MmcaClientConfigBootstrap.cs:28), whose LoadAsync fetches the
document from the app's own origin with a 15-second per-attempt timeout and a single retry after one
second (for the cold-start case where the Server host is still warming), then rethrows a second
failure on purpose, and returns a buffered stream because browser fetch streams do not support the
synchronous reads AddJsonStream performs (:12-22, :30, :33, :44-85). Only the timeout it
did not ask for is retried, never the caller's own cancellation (:87-90). ADC wires both halves
(MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:265,
MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web.Client/Program.cs:31).
The small Level-0 supporting cast fills in the rest: NotificationRoutePaths
(MMCA.Common.UI/Common/NotificationRoutePaths.cs:8), whose deep-link builder formats invariantly
because the route's :int constraint is the validation boundary (:14-20);
QrErrorCorrectionLevel, the framework's own enum for QrCodeImage so the
component's public API does not pin consumers to QRCoder's ECCLevel
(MMCA.Common.UI/Components/Sharing/QrErrorCorrectionLevel.cs:9);
RatingStars (MMCA.Common.UI/Components/Ratings/RatingStars.razor.cs:13), five
read-only stars out of a MaxValue of 5 rendered as one role="img" element whose required
AriaLabel carries the numeric figure, so the stars stay decorative rounding (whole stars fill, a
half star shows at .5 and above, :6-11, :16, :31-33, :46-54), which the ADC feedback and
speaker dashboards render
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Feedback/OrganizerSessionFeedback.razor:67);
ApiFileDownloadButton
(MMCA.Common.UI/Components/Forms/ApiFileDownloadButton.razor.cs:14), which gives browsers a plain download
link and native heads a fetch-stage-share flow, stripping directory segments from the supplied file
name so a value built from entity data cannot steer the write out of the temp directory (:21-30);
and MauiBackNavigationBridge with its
BackNavigationResult for MAUI hardware-back handling, which reports both
whether history.back() fired and whether the WebView is at the root of its stack so a host can decide
to exit (MMCA.Common.UI/Services/Navigation/MauiBackNavigationBridge.cs:19, :28). Form-factor
detection has graduated into its own device-capability layer
(IFormFactor and friends, chapter 26). The
presentational helper MoneyExtensions formats
Money for display, grouping a mixed collection by
currency so unrelated amounts never collapse under whichever symbol came first
(MMCA.Common.UI/Extensions/MoneyExtensions.cs:14, the single-price form at :29-30 and the
collection form at :33), keeping a display concern out of the
domain value object, exactly where Clean Architecture wants it.
Read the per-type sections that follow for the mechanics. The consumer-side module UIs live in the ADC
module-UI chapter (chapter 21), and the bUnit component tests plus the
Playwright/axe-core E2E suite that exercise this package are covered in the testing chapter
(chapter 27), which is where [Rubric §28, Front-End Testing]
lives.
BreakpointConstants
MMCA.Common.UI ·
MMCA.Common.UI.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/BreakpointConstants.cs:9· Level 0 · class (static)
- What it is: a one-method static helper that answers "is this viewport a mobile viewport?", so C# viewport detection and the CSS media-query boundary agree on one number.
- Depends on:
MudBlazor.Breakpoint(NuGet, imported atBreakpointConstants.cs:1). Nothing first-party. - Concept introduced, the single authoritative breakpoint.
[Rubric §22, Responsive & Cross-Browser]assesses whether a codebase has one definition of "small screen" rather than a magic number re-chosen per component; this class embodies it by making the mobile/desktop split a named predicate. The doc comment (BreakpointConstants.cs:11-15) pins the threshold as "below the sidebar-collapse threshold (MudBlazor Xs or Sm, i.e. < 960 px)", which is the same boundary the shared stylesheet collapses the sidebar at.[Rubric §20, Design System & Theming]applies for the same reason: one threshold keeps the layout, the nav drawer and the list pages switching modes together instead of at three slightly different widths. - Walkthrough: the type has exactly one member.
IsMobileBreakpoint(Breakpoint breakpoint)(BreakpointConstants.cs:16-17) is expression-bodied and returnsbreakpoint is Breakpoint.Xs or Breakpoint.Sm. That pattern is the whole rule:Mdand wider is desktop, and there is no third state. - Why it's built this way: static and dependency-free, so a component can call it without injecting anything, and moving the mobile threshold is a one-line edit paired with one CSS rule rather than a hunt through component code.
- Where it's used: exactly one production call site, DataGridListPageBase<TDto>'s viewport-change handler
NotifyBrowserViewportChangeAsync(MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DataGridListPageBase.cs:317), which setsIsMobilefrom it at line 308 and, on a desktop-to-mobile transition, resetsMobileCurrentPageto 1 and re-requests the mobile data (DataGridListPageBase.cs:324-328). That single assignment is what swaps a desktopMudDataGridfor the MobileInfiniteScrollList<TItem> card layout on every list page in both consumer apps.
IAppDialogService
MMCA.Common.UI ·
MMCA.Common.UI.Common.Interfaces·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Interfaces/IAppDialogService.cs:14· Level 0 · interface
- What it is: the framework's modal-confirmation contract, one method that asks a yes/no question and resolves once the user answers. It exists so page code never names the component library that draws the dialog.
- Depends on: nothing first-party (the file carries no
usingdirectives at all). Implemented by MudAppDialogService over MudBlazor'sIDialogService. - Concept introduced, the vendor-neutral UI facade.
[Rubric §32, Dependency & Supply-Chain]assesses whether a third-party dependency is contained behind your own contract or spread across call sites;[Rubric §14, Testability]assesses whether a unit of behavior can be exercised without its infrastructure;[Rubric §1, SOLID]covers the dependency-inversion half of the same idea. The framework applies all three the same way twice: this interface and its sibling IToastService are the only shapes pages depend on, and their two implementations are the only types in the framework that name MudBlazor'sIDialogService/ISnackbar(MudAppDialogService atMMCA.Common/Source/Presentation/MMCA.Common.UI/Services/MudAppDialogService.cs:11, doc comment at:6-10). The payoff is concrete: a bUnit test answers a confirmation prompt with a stub instead of rendering, driving and dismissing a real dialog. The doc comment (IAppDialogService.cs:3-13) also states the deliberate scope limit: only the yes/no shape is abstracted, and richer entity-specific dialogs (DeleteConfirmation) stay component-side rather than growing this contract. - Walkthrough: one member.
ConfirmAsync(string title, string message, string confirmText, string cancelText)returnsTask<bool>(IAppDialogService.cs:26). Two contract details are stated in the XML doc and honored by the implementation. First, every string parameter is documented as "already-localized" (:21-24): the facade never touchesIStringLocalizer, the caller resolves its own copy, which is what keeps the resource key next to the page that owns it (ADR-027). Second, dismissing the dialog without choosing counts as declining (:17-19), so a caller only ever has to branch ontrue. MudAppDialogService implements exactly that by collapsing MudBlazor's tri-state answer withreturn confirmed is true;(MudAppDialogService.cs:25), becauseShowMessageBoxAsyncanswersnullfor a backdrop click or an escape key press (MudAppDialogService.cs:16-18). - Why it's built this way: a four-string method with a
boolanswer is the smallest contract that covers every destructive-action prompt in the framework, and keeping it that small is what makes the vendor genuinely swappable: the whole surface an alternative renderer must satisfy is one method. It is registered byAddCommonUiFacades()alongside the toast facade (DependencyInjection,MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:231), scoped to match the MudBlazor services it wraps. See ADR-067, whose 2026-08-29 revision records the vendor choice and these two facades, and whose 2026-08-31 revision records their move into their own registration call. - Where it's used: injected by the shared framework surfaces that ask before doing something lossy: DataGridListPageBase<TDto>, the notification list, send and inbox pages,
ListPageActions, the signed-in-devices page (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Sessions.razor.cs), and theUnsavedChangesGuardcomponent. Registered for component tests by the shipped bUnit base'sServices.AddCommonUiFacades()call (MMCA.Common/Source/Hosting/MMCA.Common.Testing.UI/Infrastructure/BunitComponentTestBase.cs:54), which usesTryAddsemantics so a test that wants a recording double registers one afterwards (BunitComponentTestBase.cs:51-53).
IHomePageContent
MMCA.Common.UI ·
MMCA.Common.UI.Common.Interfaces·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Interfaces/IHomePageContent.cs:8· Level 0 · interface
- What it is: the hook that lets each consuming application supply its own landing page at the
/route without forking the shared routing or layout. - Depends on:
System.Type(BCL) and, at the consuming end,Microsoft.AspNetCore.Components.DynamicComponent, which the doc comment names as the rendering mechanism (IHomePageContent.cs:3-7). - Concept introduced, late-bound content injection into a packaged shell.
[Rubric §18, UI Architecture & Component Design]assesses whether shared UI infrastructure adapts to per-app content without duplication. The shared package owns the route:Home.razordeclares@page "/"once (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Home.razor:1), injectsIEnumerable<IHomePageContent>(Home.razor:3), and renders<DynamicComponent Type="_contentType" />when a provider resolved (Home.razor:8-11). Because the component arrives as a runtimeTyperather than a compile-time reference, the framework package renders an app's landing page without referencing the app. When no implementation is registered the page falls back to a localized welcome panel (Home.razor:12-21), so a brand-new host still renders something coherent. - Walkthrough: two read-only members.
ComponentType(IHomePageContent.cs:11) is theSystem.Typeof the Razor component to render as the home-page body.PageTitle(IHomePageContent.cs:14) is the browser-tab title, bound byHome.razor:6. - Why it's built this way: an inverted dependency (the app registers into the framework, never the reverse) is what lets the whole shell ship as a NuGet package. Compare the sibling mechanism in IUIModule: both hand the framework a
Typeor anAssemblyand let reflection do the binding, and both exist for the same reason (ADR-067). - Where it's used: implemented once per app and registered once per head. ADC registers
ADCHomePageContentas a singleton in all three heads (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:97,MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web.Client/Program.cs:45,MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/MauiProgram.cs:126), with two separate implementations, one for the web heads (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web.Client/Pages/ADCHomePageContent.cs:11) and one for MAUI (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/Pages/ADCHomePageContent.cs:8). Store does the same withStoreHomePageContent(MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:101,MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web.Client/Program.cs:41,MMCA.Store/Source/Hosts/UI/MMCA.Store.UI/MauiProgram.cs:76). - Caveats / not-in-source: the injection point is
IEnumerable<IHomePageContent>, so several registrations do not fail; which one wins is decided byHome.razor's selection code (Home.razor:23onward), not by this interface. Every current host registers exactly one.
ComponentLifetimeExtensions
MMCA.Common.UI ·
MMCA.Common.UI.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/ComponentLifetimeExtensions.cs:18· Level 0 · class (static, extension methods)
- What it is: one extension method,
LifetimeToken(this CancellationTokenSource?), that gives a component a cancellation token it can read at any point of its lifetime without risking an exception. - Depends on:
System.Threading.CancellationTokenSource,CancellationTokenandObjectDisposedException(BCL). Nothing first-party. - Concept introduced, the always-safe lifetime token.
[Rubric §15, Best Practices & Code Quality]assesses whether a recurring hazard has one shared answer. ReadingCancellationTokenSource.TokenafterDispose()throwsObjectDisposedException, and a Blazor component's async continuation can easily run after the component is gone. The extension converts that failure mode into the correct behavior: a component that has ended gets a token that is already cancelled, so the in-flight call stops instead of throwing out of a dead render tree. - Walkthrough:
LifetimeToken(ComponentLifetimeExtensions.cs:26-42) has three outcomes. Anullsource, or one whoseIsCancellationRequestedis already true, returnsnew CancellationToken(canceled: true)(:28-31). Otherwise it returnssource.Token(:35) inside atry; anObjectDisposedException(a source disposed without being cancelled first) is caught and also returns a cancelled token (:37-41), with the comment that the component is gone all the same. - Why it's built this way: an extension on the nullable source, rather than a wrapper type, so a component keeps its familiar
_ctsfield and writes_cts.LifetimeToken()where it used to write_cts.Token.nullis treated as ended (:28), which covers a component whose source was never created. - Where it's used: LatestLoadGuard reads its token through it (
MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/LatestLoadGuard.cs:58), and theResultUiExtensionsexamples show_cts.LifetimeToken()as the call-site shape (MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/ResultUiExtensions.cs:47,:63). The convention is enforced byArchitectureRules.ComponentsReadTheirTokenThroughLifetimeToken(MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Ui/ArchitectureRules.LifetimeTokens.cs:28), which fails the build on a direct_cts.Tokenread, wrapped for subclassing byLifetimeTokenConventionTestsBase(MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/Ui/LifetimeTokenConventionTestsBase.cs:13). It is on the shipped public surface (MMCA.Common/Source/Presentation/MMCA.Common.UI/PublicAPI.Shipped.txt:1391).
LatestLoadGuard
MMCA.Common.UI ·
MMCA.Common.UI.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/LatestLoadGuard.cs:38· Level 0 · class (sealed,IDisposable)
- What it is: a small per-page helper that keeps a routed component showing the load it asked for last, by giving every load a generation number and a cancellation token and cancelling the previous one as the next begins.
- Depends on:
System.Threading.CancellationTokenSourceandObjectDisposedException.ThrowIf(BCL), and ComponentLifetimeExtensions for the token it hands out. - Concept introduced, generation-guarded supersession.
[Rubric §19, State Management & Data Flow]assesses the correctness of in-flight asynchronous state, and this is the canonical bug it looks for. The doc comment states it precisely (LatestLoadGuard.cs:5-10): Blazor reuses a routed component instance across route-parameter changes, so a page that opens entity 100 (slow response) and then navigates to entity 101 (fast response) receives 100's late answer after 101 has already rendered. Assigning it unconditionally leaves the URL on 101 while the page holds 100, and any action bound to the loaded entity then fires against the wrong record. Cancellation alone does not fix this, because a fetch that ignores its token still completes; the integer generation is the authoritative check. The same pattern appears at component scope inside MobileInfiniteScrollList<TItem>, which snapshots a generation before awaiting and drops the result if the world moved;LatestLoadGuardis that idea extracted into a reusable object so a detail page gets it in three lines.[Rubric §15, Best Practices & Code Quality]applies: the alternative is every page hand-rolling a token source, a counter and a dispose path. - Walkthrough: three private fields (
LatestLoadGuard.cs:40-42): the currentCancellationTokenSource?, theint _generation, and a_disposedflag.Begin()(:50-59) is the entry point. It throws if the guard is disposed (:52), cancels and disposes the previous load through the privateCancelAndDisposeCurrent()(:54), publishes a fresh token source (:55), increments the generation (:56), and returns the pair(CancellationToken Token, int Generation)(:58), with the token read as_cts.LifetimeToken()rather than_cts.Token(:58) so it is the always-safe token of ComponentLifetimeExtensions. Returning a tuple rather than exposing two properties is what makes the generation a snapshot: the caller holds the value it started with, so a laterBegin()cannot retroactively change what it compares against.IsCurrent(int generation)(:67) is the check after the await:!_disposed && generation == _generation. Disposal counts as not-current, so a component torn down mid-fetch also drops its answer instead of assigning into a dead render tree.Dispose()(:70-79) is idempotent via the_disposedearly return (:72-75) and cancels the in-flight load on the way out.CancelAndDisposeCurrent()(:81-91) null-guards, then cancels, disposes and nulls the source, so the guard never double-disposes a token source and never leaks one.- The usage shape is spelled out as a
<code>block in the doc comment (:15-31): aprivate readonly LatestLoadGuard _load = new();field,var (token, generation) = _load.Begin();at the top ofOnParametersSetAsync, anif (!_load.IsCurrent(generation)) { return; }immediately after the await, andpublic void Dispose() => _load.Dispose();.
- Why it's built this way: deliberately not thread-safe, and the doc comment says so in bold (
:32-36). It is built for the renderer's synchronization context, where component lifecycle methods and event callbacks are already serialized, so the fields need no interlocking and the type stays allocation-cheap. That is a contract, not an oversight: sharing one instance across threads is documented as unsupported. - Where it's used: the framework's
DetailPageBaseexposes one asprotected LatestLoadGuard LoadGuard { get; } = new();and disposes it (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DetailPageBase.cs:43,:95), so every detail page derived from it inherits the guard.RoleAdminEditholds its own_loadand runs theBegin()/IsCurrent(generation)shape directly (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Administration/RoleAdminEdit.razor.cs:42,:162,:174,:187), and ADC's public event, session and speaker detail pages andSessionCreatealso call it (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/Events/PublicEventDetail.razor.cs,Pages/Public/Sessions/PublicSessionDetail.razor.cs,Pages/Public/Speakers/PublicSpeakerDetail.razor.cs,Pages/Sessions/SessionCreate.razor.cs). It is on the shipped public surface (MMCA.Common/Source/Presentation/MMCA.Common.UI/PublicAPI.Shipped.txt:37-41). It is also exercised by LatestLoadGuardTests (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Common/LatestLoadGuardTests.cs:11, six test methods coveringBegincancelling the prior token, generation advance,IsCurrentafter supersession, and behavior after disposal)[Rubric §28, Front-End Testing];DetailPageBaseTestscovers the base-class wiring (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Common/DetailPageBaseTests.cs). - Caveats / not-in-source: the guard is not thread-safe by contract, and a page that bypasses it (assigning a fetch result unconditionally) is not caught by any analyzer; adoption is by
DetailPageBaseinheritance or by hand, asRoleAdminEditdoes.
NavSection
MMCA.Common.UI ·
MMCA.Common.UI.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/NavSection.cs:7· Level 0 · enum
- What it is: classifies a sidebar entry into one of three audience groups: everyone, signed-in users, or administrators.
- Depends on: nothing. Consumed by NavItem and by the shared nav menu.
- Concept introduced, audience as a first-class navigation axis.
[Rubric §25, Navigation & Information Architecture]assesses whether the menu structure is declarative and audience-aware rather than a hand-maintained pile of conditionals.[Rubric §11, Security]touches it too, but with an important distinction worth internalizing early: the section is a grouping hint, not an authorization check. What actually hides a link isRequiredRole/RequiredClaimon NavItem, evaluated by the menu (MMCA.Common/Source/Presentation/MMCA.Common.UI/Layout/NavMenu.razor:198-199), and what actually protects the destination is page-level and API-level authorization. - Walkthrough: three values in declaration order, and the order is itself a contract because the doc comment states sections render in enum declaration order (
NavSection.cs:5).General(:10) is for items visible to everyone, anonymous and authenticated alike.User(:13) is for signed-in non-admin items.Admin(:16) is for administrator items. - Why it's built this way: an enum rather than a string gives the renderer exhaustive, typo-proof matching, which is exactly what
NavMenu.razorrelies on when it partitions the flattened item list into three collections withi.Section is NavSection.General/User/Admin(NavMenu.razor:202-204). It is a plain C# enum rather than a smart enumeration because no member needs to carry data or behavior, which is the default this codebase commits to (ADR-104). - Where it's used: the
Sectionparameter of NavItem (NavItem.cs:16, defaulting toGeneral), and the three-way partition atNavMenu.razor:202-204. NotificationUIModule shows both non-default values in one file: its inbox item isSection: NavSection.User(MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/NotificationUIModule.cs:19) and its push-management item isSection: NavSection.Admin(:20).
RoutePaths
MMCA.Common.UI ·
MMCA.Common.UI.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/RoutePaths.cs:7· Level 0 · class (static)
- What it is: the route-path constants owned by the shared UI package itself. Module-specific routes live in their own
*RoutePathsclasses. - Depends on: nothing.
- Concept introduced, one source of truth for route strings.
[Rubric §25, Navigation & Information Architecture]also covers URL hygiene: a literal"/profile/sessions"typed into four components is four places to get a rename wrong. The class comment (RoutePaths.cs:3-6) states both halves of the convention: centralized paths shared across all UI modules and hosts live here, and module-specific paths live in their own class. That convention is followed consistently across the workspace, by NotificationRoutePaths in this package and byConferenceRoutePaths,EngagementRoutePathsandIdentityRoutePathsin the consumer modules. - Walkthrough: two
public static readonly stringmembers.Home = "/"(RoutePaths.cs:9).Sessions = "/profile/sessions"(:16), the signed-in-devices page. Its doc comment (:11-15) records why it belongs to the framework rather than to an app: the page is framework-owned (MMCA.Common.UI.Pages.Auth.Sessions), lists the user's live refresh sessions with per-device and account-wide sign-out, and is reachable from the shared nav menu's authenticated section, so a consuming app gets it without doing any routing work.
- Why it's built this way:
static readonlyrather thanconstis sufficient because these strings are consumed in navigation andHrefexpressions, not in attribute arguments. That has one consequence worth knowing: a@pagedirective still needs its own literal, soHome.razor:1writes@page "/"directly and this constant covers only the linking and navigating side.Sessionsshows the cost of the convention: the route literal appears in the page's own@pagedirective and again here, and only the tests hold the two together. - Where it's used:
RoutePaths.Homebacks the navbar brand link (MMCA.Common/Source/Presentation/MMCA.Common.UI/Layout/NavMenu.razor:18), the Home nav link (NavMenu.razor:58), and the first breadcrumb of the sessions page (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Sessions.razor.cs:68).RoutePaths.Sessionsbacks the authenticated-section nav link (NavMenu.razor:142) and is asserted by name in both repos' component tests:MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Layout/NavMenuTests.cs:76pins that the link renders inside.nav-auth-section,:75pins its position,:88pins that it is absent for an anonymous user, andMMCA.Store/Tests/Modules/Identity/MMCA.Store.Identity.UI.Tests/Pages/Users/Profile/ProfileTests.cs:67pins that the profile page links to it. - Caveats / not-in-source:
Sessionsis still listed inPublicAPI.Unshipped.txt(MMCA.Common/Source/Presentation/MMCA.Common.UI/PublicAPI.Unshipped.txt:339) whileHomeis baselined inPublicAPI.Shipped.txt:916, so the two members sit at different points in the public-API baseline cycle.
ToastSeverity
MMCA.Common.UI ·
MMCA.Common.UI.Common.Interfaces·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Interfaces/IToastService.cs:8· Level 0 · enum
- What it is: the framework's own five-level scale for how prominent a toast is, and therefore which color and icon the host renders it with.
- Depends on: nothing. Declared above IToastService in the same file.
- Concept reinforced, keeping the vendor enum out of call sites. The facade idea is taught at IAppDialogService; this enum is the part of it that is easy to skip. A contract that abstracts the snackbar but takes
MudBlazor.Severityas a parameter has not abstracted anything, because every caller still references the vendor assembly. The doc comment says exactly that (IToastService.cs:3-7): the five levels mirror what every component library exposes, so a host maps them one-to-one without losing anything, and "naming them here is what keeps the vendor's own severity enum out of page code".[Rubric §32, Dependency & Supply-Chain]and[Rubric §9, API & Contract Design]both apply: the mapping toMudBlazor.Severitylives in one private method inside MudToastService, so swapping renderers is a change to oneswitch. - Walkthrough: five explicitly numbered members, each documented by what it means for the user rather than by color.
Normal = 0(IToastService.cs:11), neutral with no color emphasis.Info = 1(:14), something happened that the user did not ask for.Success = 2(:17), the action the user asked for completed.Warning = 3(:20), completed partially or with something worth knowing.Error = 4(:23), the action failed. The explicit values keep the enum stable if members are ever reordered. - Why it's built this way: five levels rather than four, because
Normal(an uncolored toast) is a distinct affordance fromInfo, and not more, because there is no shape beyond these that the framework raises.Infois the default parameter value on bothShowPersistentandShowAction(:72,:100), which is the neutral choice for an unprompted message. - Where it's used: the
severityparameter ofIToastService.Show,ShowPersistentandShowAction; theseverityparameter of ResultUiExtensions.NotifyOnFailure, where it defaults toToastSeverity.Error(MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/ResultUiExtensions.cs:293); and MudToastService, the one type that maps it toMudBlazor.Severity(MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/MudToastService.cs:27).
UISharedAssemblyReference
MMCA.Common.UI ·
MMCA.Common.UI·MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:335· Level 0 · class
- What it is: an empty marker class whose only job is to give code a compile-checked
typeof(...).Assemblyhandle on the shared UI assembly. - Depends on: nothing.
- Concept introduced, the assembly-marker type. Reflection over an assembly needs an
Assemblyinstance, and there are two ways to get one: a string (Assembly.Load("MMCA.Common.UI"), which fails at run time when someone renames the project) or a type reference (typeof(UISharedAssemblyReference).Assembly, which fails at compile time and is carried along by a rename refactoring). Every layer of the framework ships an equivalent marker; this is the UI layer's.[Rubric §15, Best Practices & Code Quality]assesses exactly this kind of refactor-safety over stringly-typed lookups. - Walkthrough: a single declaration using the semicolon type body,
public class UISharedAssemblyReference;(DependencyInjection.cs:335), with its doc comment on line 217. It shares a file with DependencyInjection but is declared at namespace scope beneath it, outside that static class, because a type nested inside a static class could not serve as a public marker the same way. It carries no members, so nothing can accidentally depend on state it does not have. - Why it's built this way: type-only, public and empty is the whole point. It is the assembly's identity expressed as a symbol the compiler tracks.
- Where it's used: the architecture fitness suite is the real consumer.
CommonArchitectureMapregisters the assembly as the framework's UI layer withFramework(Layer.Ui, typeof(Common.UI.UISharedAssemblyReference).Assembly)(MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/CommonArchitectureMap.cs:27), which is what lets the shared layer-dependency rules know which assembly is the UI layer;AnonymousEndpointTestsincludes it in the assemblies it scans (MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/Api/AnonymousEndpointTests.cs:19); andNavigationContractTestsenumerates its types withtypeof(UI.UISharedAssemblyReference).Assembly.GetTypes()(MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/Ui/NavigationContractTests.cs:105).[Rubric §34, Architecture Governance & Documentation]applies: this one-line type is what makes a layer boundary machine-checkable. - Caveats / not-in-source: the doc comment (
DependencyInjection.cs:334) offers "e.g., for Scrutor scanning" as the motivating case, but no Scrutor registration in this repo takes its scan root from this marker.AddUIModule<TModule>()scansFromAssemblyOf<TModule>()(DependencyInjection.cs:324), taking the root from the module descriptor's own assembly instead. Trust the call sites: the current consumers are the architecture tests.
IToastService
MMCA.Common.UI ·
MMCA.Common.UI.Common.Interfaces·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Interfaces/IToastService.cs:37· Level 1 · interface
- What it is: the framework's contract for transient user notifications ("toasts"), covering four named severities, a runtime-chosen severity, a persistent two-line notification, and a toast that carries a clickable action.
- Depends on: ToastSeverity (same file). Implemented by MudToastService over MudBlazor's
ISnackbar. - Concept introduced, fire-and-forget by design. The facade rationale is taught at IAppDialogService; what is specific here is that every method returns
void. The doc comment explains why (IToastService.cs:31-35): during server-side prerender there is no toast host at all, so the call is a silent no-op, and a contract that reported whether the message rendered would force every call site to handle a condition it can do nothing about.[Rubric §24, Forms, Validation & UX Safety]assesses whether outcomes surface to the user in a recoverable way;[Rubric §27, Internationalization]applies because every parameter is documented as already localized, which pushes resource resolution out to the page that owns the key. That last rule is enforced, not merely documented: an architecture fitness regex fails the build on a literal first argument to any toast method (Toast\.(?:Success|Info|Warning|Error|Show|ShowPersistent|ShowAction)\(\s*\$?", atMMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Ui/ArchitectureRules.LocalizedText.cs:14, with the comment at:12-13recording that it is the same guard that previously watched directISnackbaruse at:9). - Walkthrough: seven members, in three tiers.
- The four named severities,
Success(IToastService.cs:41),Info(:45),Warning(:49) andError(:53), each taking one already-localized message. MudToastService implements each as a one-linesnackbar.Add(message, Severity.X)(MudToastService.cs:15-24). Show(string message, ToastSeverity severity)(:62) is the same thing with the level as a parameter, and the doc comment names its motivating caller: ResultUiExtensions.NotifyOnFailure, which carries the severity through as an argument rather than picking one of the four (:55-58).ShowPersistent(string title, string body, ToastSeverity severity = ToastSeverity.Info)(:72) is the push-notification shape: an emphasized title above a body, staying on screen until dismissed. The reasoning (:64-67) is that a message arriving unprompted must not expire before the user has looked at the screen. MudToastService builds it as a render fragment, a<strong>title, a<br>, then the body (MudToastService.cs:41-51).ShowAction(string message, string actionText, Func<Task> onAction, ToastSeverity severity = ToastSeverity.Info, bool requireInteraction = false)(:96-101) is the undo / view-it / retry shape a bare message cannot express. Two contract details are documented rather than enforced. First, the callback runs outside any render callback, so nothing catches what it throws: a caller whose work can fail must guard it and raise its own failure toast (:78-81, restated at:86-88). Second,requireInteraction: truepins the toast open until the user dismisses it or takes the action, and the MudBlazor implementation additionally renders it filled, following the same emphasis conventionShowPersistentuses, "because a toast that waits for the user has to look like it is waiting" (:90-95).
- The four named severities,
- Why it's built this way: a small,
void-returning, already-localized contract is what allows the vendor to appear in exactly one class. It is registered scoped byAddCommonUiFacades()(DependencyInjection,MMCA.Common.UI/DependencyInjection.cs:230) to match the lifetime of the MudBlazorISnackbarit wraps. See ADR-067. - Where it's used: essentially everywhere a page reports an outcome. Inside the framework package: DataGridListPageBase<TDto>, MobileInfiniteScrollList<TItem>,
ListPageActions, the three notification pages, the sessions page, and theUnsavedChangesGuard,SharePageButton,ApiFileDownloadButtonandNotificationListenercomponents. Outside it, every consumer page reaches it indirectly through ResultUiExtensions.NotifyOnFailure. Component tests resolve it from the shipped bUnit base (BunitComponentTestBase.cs:54, whose comment at:50-51records that without it a consumer's component test fails to resolveIToastServiceand each repo ends up re-registering the same pair).
NavItem
MMCA.Common.UI ·
MMCA.Common.UI.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/NavItem.cs:20· Level 1 · record
- What it is: the immutable description of one sidebar entry a UI module contributes: title, href, icon, the resource type its title resolves against, optional role, claim and permission gates, its NavSection, and an optional collapsible group.
- Depends on: NavSection;
System.Type(BCL). - Concept introduced, navigation as data contributed by modules.
[Rubric §25, Navigation & Information Architecture]assesses modular, role-aware navigation. The shared menu never knows which modules exist: it injectsIEnumerable<IUIModule>, flattens every module'sNavItems, filters, partitions and renders (MMCA.Common/Source/Presentation/MMCA.Common.UI/Layout/NavMenu.razor:219-228). That mirrors the server-side IModule contract one layer up (ADR-059 for the server, ADR-067 for this one).[Rubric §27, Internationalization]applies throughTitleResource(ADR-027).[Rubric §11, Security]and ADR-020 apply throughRequiredPermission. - Walkthrough: a positional record declared on one line (
NavItem.cs:20) with nine parameters, five of them optional:Title,Href,Icon: required, positional.Type TitleResource: required, and the fourth positional parameter. This is the localization contract, and the doc comment states it precisely (NavItem.cs:13-18):TitleandGroupare resource keys, resolved againstTitleResourceat render time, per-circuit, so the menu follows the active culture. A key the resource type does not declare renders as the raw string, which is what makes a not-yet-translated entry legible instead of blank.NavMenu.razorimplements exactly that, callingLocalizerFactory.Create(item.TitleResource)[item.Title]for an item (NavMenu.razor:166-170) andLocalizerFactory.Create(group.First().TitleResource)[group.Key]for a group heading (NavMenu.razor:172-180), with the ADR-027 rule restated in a code comment at:163-165.string? RequiredRole = nullandstring? RequiredClaim = null: render gates. The menu applies them asitem.RequiredRole is null || _user?.IsInRole(item.RequiredRole) == trueand the equivalent claim-type test (NavMenu.razor:221-222).string? RequiredPermission = null: a third, permission-based render gate, layered on top of role and claim. The menu applies it asitem.RequiredPermission is null || _user.HasPermissionClaim(item.RequiredPermission)(NavMenu.razor:223), the same permission-claim mechanism ADR-020 uses on the write side, so a nav entry can be hidden by permission instead of only by role.NavSection Section = NavSection.General: which sidebar group the item lands in (NavMenu.razor:226-228).string? Group = null: nests the item inside a collapsibleMudNavGroup; the menu groups by it withGroupBy(i => i.Group)in each of the three sections (NavMenu.razor:60,:83,:110).
- Why it's built this way: a positional record gives value semantics and a one-line construction per entry, which is what makes a module's
NavItemsread as a small declarative list. MakingTitleResourcea required positional parameter rather than an optional nullable one is the load-bearing design choice: there is no way to register a nav item that bypasses localization, so "all visible text follows the selected language" holds for the menu by construction rather than by review. ADR-067 records the shape at this exact line (NavItem.cs:20).RequiredPermissionwas added as a third, independent gate rather than folded intoRequiredClaim, so a caller can express "hide by permission" without hand-building the permission claim type/value pair itself. - Where it's used: returned from the
NavItemsproperty of every IUIModule implementation and rendered byNavMenu.razor. NotificationUIModule is the framework's own example and shows both the minimal and the maximal form:new("Nav.NotificationInbox", NotificationRoutePaths.NotificationInbox, Icons.Material.Filled.Inbox, typeof(SharedResource), Section: NavSection.User)(MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/NotificationUIModule.cs:19) andnew("Nav.PushNotifications", NotificationRoutePaths.Notifications, Icons.Material.Filled.NotificationsActive, typeof(SharedResource), RoleNames.Organizer, Section: NavSection.Admin, Group: "Notifications")(:20). Covered by NavMenuTests (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Layout/NavMenuTests.cs), which drives the menu through aStubUiModule(IReadOnlyList<NavItem> navItems)(:198). - Caveats / not-in-source:
RequiredRoleandRequiredClaimcontrol rendering only. They hide a link; they do not authorize the destination. The menu keeps a section-level authentication check alongside the per-item one deliberately (NavMenu.razor:104-105), but page-level and API-level authorization remain the enforcing gates.
IUIModule
MMCA.Common.UI ·
MMCA.Common.UI.Common.Interfaces·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Interfaces/IUIModule.cs:11· Level 2 · interface
- What it is: the UI-side counterpart to the server's IModule. A pluggable UI module declares its navigation entries, its Razor assembly for route discovery, and optionally components it injects into the top app bar and the root layout.
- Depends on: NavItem;
System.Reflection.Assembly(IUIModule.cs:1). - Concept introduced, plugging into a packaged application shell.
[Rubric §18, UI Architecture & Component Design]assesses whether there is a coherent composition model. ADR-067 states the problem this solves: before it, every Blazor head owned its ownApp/Routes/ layout / nav markup, so adding a module meant editing the host (a new nav link, a new assembly in the router, a new drawer in the layout), and two apps built on the same framework drifted apart in shell behavior even where they agreed. The framework already shipped the shell; what was missing was a contract letting a module contribute into it. Resolution isIEnumerable<IUIModule>from DI, so the shell composes whatever is registered without naming any module.[Rubric §25, Navigation & Information Architecture]applies because nav is contributed rather than hard-coded, and[Rubric §7, Microservices Readiness]applies in the same spirit as the server contract: a module that can be added or removed by one registration line is a module that can move. - Walkthrough: five members, three of them defaulted.
IReadOnlyList<NavItem> NavItems(IUIModule.cs:14): the module's contribution to the shared sidebar. Flattened byNavMenu.razor:256with.SelectMany(m => m.NavItems).Assembly Assembly(:17): the assembly containing the module's Razor pages. The router consumes it asAdditionalAssemblies="UIModules.Select(m => m.Assembly)"(MMCA.Common/Source/Presentation/MMCA.Common.UI/Routes.razor:13), which is what makes a module's@pageroutes discoverable at run time with no central route table to edit.IReadOnlyList<Type> AppBarComponentTypes => [](:20): a default interface member returning an empty collection expression. Components listed here render inside the top app bar (MMCA.Common/Source/Presentation/MMCA.Common.UI/Layout/MainLayout.razor:155, also gathered byNavMenu.razor:216).IReadOnlyList<Type> LayoutComponentTypes => [](:23): the same idea at the root-layout level, for drawers, overlays and headless listeners (MainLayout.razor:156).IReadOnlyList<Type> ContentHeaderComponentTypes => [](:29): components rendered at the top of the main content region, above the page body and beside the offline banner, for a banner the user must see before the page itself (:25-28).MainLayoutgathers them withUIModules.SelectMany(m => m.ContentHeaderComponentTypes)(MainLayout.razor:157) and renders them in a block documented atMainLayout.razor:77.
- Why it's built this way: the three default interface members are what keep the simple case simple. A module that only contributes navigation implements two properties, not five, and can gain app-bar, layout or content-header contributions later without a breaking change to anything already written. Passing an
Assemblyrather than a list of page types keeps route discovery reflective, so adding a page is never a framework edit. - Where it's used: implemented by module descriptors across the workspace: the framework's own NotificationUIModule (
MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/NotificationUIModule.cs:15), ADC'sConferenceUIModule(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/ConferenceUIModule.cs:14),EngagementUIModule(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/EngagementUIModule.cs:17),IdentityUIModule(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/IdentityUIModule.cs:18) and the MAUI-head-onlyDeviceUIModule(MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/DeviceUIModule.cs:18), plus Store'sCatalogUIModule(MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.UI/CatalogUIModule.cs:13),SalesUIModule(MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.UI/SalesUIModule.cs:16),IdentityUIModule(MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.UI/IdentityUIModule.cs:13) andMauiUIModule(MMCA.Store/Source/Hosts/UI/MMCA.Store.UI/MauiUIModule.cs:14). The optional members earn their keep in practice:NotificationUIModulecontributesNotificationBellto the app bar andNotificationListenerto the layout (NotificationUIModule.cs:23-25), Store'sSalesUIModulecontributesCartButtonplusCartDrawerandOrphanOrderRecovery(SalesUIModule.cs:30-32), ADC'sEngagementUIModulecontributesLiveEventListener(EngagementUIModule.cs:31), and ADC'sDeviceUIModulecontributes five headless native listeners at once (DeviceUIModule.cs:33). Registration goes throughAddUIModule<TModule>()(see DependencyInjection), which each module's own one-lineAdd{Module}UI()delegates to, for exampleMMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.UI/DependencyInjection.cs:19. A stub implementation drives the menu tests (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Layout/NavMenuTests.cs:308) and another drives the backend-less component gallery (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Gallery/Stubs/GalleryUIModule.cs:14).
IEntityService<TEntityDTO, TIdentifierType>
MMCA.Common.UI ·
MMCA.Common.UI.Common.Interfaces·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Interfaces/IEntityService.cs:20· Level 3 · interface
- What it is: the generic CRUD contract every module page injects to talk to its API endpoints. Seven asynchronous members, every one of them returning a Result.
- Depends on: Result and ErrorType from
MMCA.Common.Shared.Abstractions(IEntityService.cs:1); IBaseDTO<TIdentifierType> as theTEntityDTOconstraint (:21) and BaseLookup<TIdentifierType> as a return type (:41), both fromMMCA.Common.Shared.DTOs(:2). Implemented by EntityServiceBase<TEntityDTO, TIdentifierType>. - Concept introduced, the Result railway crossing the HTTP boundary intact.
[Rubric §18, UI Architecture & Component Design]assesses separation of components from their data access, and[Rubric §9, API & Contract Design]assesses whether the client contract mirrors the server surface. But the teaching point is the second paragraph of the doc comment (IEntityService.cs:10-16): every member returns "the same railway type the server produced, read back from its Problem Details response with the originalErrorTypeintact". A page therefore branches on the outcome instead of catching an exception. That is what makes a 404 renderable as an empty state and a 401 as a redirect without pattern-matching message text, and it is why the sibling helper ResultUiExtensions exists. ADR-013's 2026-08-27 revision records this as the retirement of the UI-layer deviation, and ADR-094 records the surrounding data-access contract. Note also the layering rule this respects:MMCA.Common.UImay referenceMMCA.Common.Sharedonly, which is why both constraints come fromSharedand never from Application or Domain[Rubric §3, Clean Architecture]. - Walkthrough: two generic constraints (
:21-22) bindTEntityDTOto IBaseDTO<TIdentifierType> and requireTIdentifierType : notnull. Then seven members, every one ending in a defaultedCancellationToken:GetAllAsync(bool includeFKs, bool includeChildren, CancellationToken)(:25-28) returnsResult<IReadOnlyList<TEntityDTO>>; the two flags map to API query options.GetPagedAsync(Dictionary<string, (string Operator, string Value)> filters, int pageNumber, int pageSize, string? sortColumn, string? sortDirection, bool includeChildren, CancellationToken)(:31-38) returnsResult<(IReadOnlyList<TEntityDTO> Items, int TotalItems)>. TheTotalItemshalf of that tuple is what makes server-side paging work at all: a grid needs the total to size its pager without fetching the rest of the table. The filter dictionary is the client half of the dynamic query contract (ADR-034).GetAllForLookupAsync(string nameProperty, CancellationToken)(:41-43) returns lightweightId + NameBaseLookup<TIdentifierType> items for dropdowns and autocompletes, so a picker never pulls whole entities.GetByIdAsync(TIdentifierType id, bool includeChildren, CancellationToken)(:50-53) returnsResult<TEntityDTO>, and the doc comment (:45-49) pins the contract that used to be ambiguous: a missing entity is anErrorType.NotFoundfailure, never a success carrying null. That is what lets a detail page writeif (result.IsNotFound())instead of a null check that cannot distinguish "absent" from "the call failed".AddAsync(TEntityDTO entity, CancellationToken)(:56-58) returns the server-assigned DTO including its generated id.UpdateAsync(TEntityDTO entity, CancellationToken)(:61-63) andDeleteAsync(TIdentifierType id, CancellationToken)(:66-68) return the non-genericResult, because there is no value to carry back, only a verdict.
- Why it's built this way: an interface keeps Blazor components testable (mock the contract, no HTTP) and hides the API URL structure behind a typed surface. The generic-over-DTO shape is the client mirror of the server's generic controller layer (ADR-034), which is why one base class implements it for every entity in the system. The uniform
Resultreturn is deliberate rather than convenient: mixing nullable returns for reads withboolfor writes forces each call site to invent its own error story, whereas returningResulteverywhere means one small set of helpers covers all seven members. - Where it's used: implemented for every entity by EntityServiceBase<TEntityDTO, TIdentifierType> (
MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/EntityServiceBase.cs:43-47, which also composes an optional IUiReadCache) and consumed by every module CRUD page, most of them through DataGridListPageBase<TDto>. Registration is automatic:AddUIModule<TModule>()runs a Scrutor scan over the module's assembly that picks up every class assignable toIEntityService<,>and registers it scoped as its implemented interfaces (DependencyInjection,MMCA.Common.UI/DependencyInjection.cs:323-327). The(Items, TotalItems)shape ofGetPagedAsyncis also the shape MobileInfiniteScrollList<TItem>'s page-fetch delegate expects.
ResultUiExtensions
MMCA.Common.UI ·
MMCA.Common.UI.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/ResultUiExtensions.cs:74· Level 4 · class (static, extension methods)
What it is: the page-side half of the Result transport. It is the four things a Blazor page ever does with a failed Result, written once so no page hand-rolls them again: unwrap the value, push the message into an inline alert, raise it as a toast, or branch on why it failed.
Depends on: Result, Error, ErrorType and ErrorTypeSeverity from
MMCA.Common.Shared.Abstractions(ResultUiExtensions.cs:5);ProblemDetailsResultReaderand the synthesized-failure codes ofHttpResultExecutor(:6,:9); IToastService and ToastSeverity (:7); the framework'sSharedResourceresource pair (:8);Microsoft.Extensions.Localization.IStringLocalizer(:4);System.Resources.ResourceManager(:3);System.Diagnostics.CodeAnalysis.NotNullWhenAttributeandSuppressMessageAttribute(:1).Concept introduced, localize-with-pass-through, and severity-ordered deduplication.
[Rubric §24, Forms, Validation & UX Safety]assesses whether a failure reaches the user as one clear, actionable sentence;[Rubric §27, Internationalization]assesses whether that sentence follows the active culture;[Rubric §15, Best Practices & Code Quality]assesses whether the same five lines get re-typed per page. Two mechanisms carry all three.- Pass-through localization (
:22-27): every message is looked up as a resource key, and one the localizer does not declare renders verbatim. That is what lets a single call site handle both an API error whose text the server already localized and a client-side error whoseMessageis a resource key. - Code-keyed fallback for synthesized failures (
:27-33): a failure the client built itself, with no server-phrased message to pass through (a bodiless HTTP status, a transport failure, a client timeout), is looked up by its error code instead:Http.{status}, then the genericHttp.Statusformat,Http.TransportFailure,Http.Timeout. A code the supplied localizer does not hold resolves from the framework's ownSharedResourcepair for the current UI culture, so a consumer page passing its ownIStringLocalizer<PageType>(which holds noHttp.*keys) still shows the translated sentence; the English message is the last resort. The class holds a privateResourceManager SharedResources = new(typeof(SharedResource))(:84) and the format keyHttpStatusFallbackKey = "Http.Status"(:78) for this. - Severity-ordered deduplication (
:36-39): messages are made distinct withStringComparer.Ordinaland ordered most-severe-first via ErrorTypeSeverity, so a real 403 or 500 leads and an incidental validation message never buries it. The shape this guards against is common onceResult.Combineaggregates invariants: the same sentence arriving under several codes now reads as one sentence. This is the client mirror of the server-side status selection recorded in ADR-013, using the very same ranking type hoisted intoShared(MMCA.Common/Source/Core/MMCA.Common.Shared/Abstractions/ErrorTypeSeverity.cs:59) so both edges classify one aggregate identically.
The class doc even ships a before/after pair (
:42-73) contrasting the oldtry/catch (Exception ex)shape withif (result.TryGetValue(out var dto)) { ... } else { result.NotifyOnFailure(Toast, L); }, both reading the token as_cts.LifetimeToken()(ComponentLifetimeExtensions).- Pass-through localization (
Walkthrough: eleven public members plus five private helpers, in four tiers.
- Unwrapping.
TryGetValue<T>(this Result<T>, [NotNullWhen(true)] out T? value)(:103-118) reads likeDictionary.TryGetValueso the success and failure branches sit side by side. The implementation carries a subtle correctness note in its comment (:107-109): the failure branch is decided byresult.IsFailure, not by whether the value is null, because for a value type (a(Items, TotalItems)tuple, anintcount)defaultis never null and a null test alone would report every failure as a success. The three-argument overload (:137-154) hands the errors back on the failing branch and documents one edge honestly (:123-129): a success carrying a null value also takes the failing branch, and a success has no errors, soerrorscomes back empty. The framework's own services never produce that shape (a 2xx with no value fails withHttp.EmptyResponse), but a caller switching on the error list should not assume it is non-empty. - Composing the message.
LocalizedErrorMessages(this Result, IStringLocalizer?)(:166-180) returns an empty list for a success, so a caller can bind it without a null or success check, and otherwise orders byErrorTypeSeverity.Rankdescending, localizes each error through the privateLocalizeError(:177) rather than its bare message, drops blanks, and takes ordinal-distinct values (:175-179).LocalizedErrorMessage(:190-194) joins that list with a space and returnsnullfor a success.LocalizeDistinct(IEnumerable<string>?, IStringLocalizer?)(:206-218) gives the same treatment to a plain message list, specifically theMudForm.Errorsshape whose entries are resource keys produced by the model's DataAnnotations; it preserves original order rather than re-ranking, since those entries carry noErrorType. - Rendering.
OnFailureSetError(this Result, Action<string?> setError, IStringLocalizer?)(:248-255) hands the composed message to the page's own error field, the one an inlineMudAlertor thePageErrorStatecomponent renders (its doc comment names it by its current home,MMCA.Common.UI.Components.PageState.PageErrorState, at:221-222, matching the file atMMCA.Common/Source/Presentation/MMCA.Common.UI/Components/PageState/PageErrorState.razor:1), and clears it on success by passingnull(:253), which is why a retry that succeeds does not leave a stale alert on screen.NotifyOnFailure(this Result, IToastService, IStringLocalizer?, ToastSeverity = Error)(:289-305) raises the composed message as one toast, never one per error, callingtoast.Show(message, severity)only when the composed message is non-null (:298-302). Both return the same result instance so the call can sit inline, and both have aResult<T>overload that delegates to the non-generic one and returns the typed result (:260-264,:310-318), which is what keeps(await Service.AddAsync(dto, token)).NotifyOnFailure(Toast, L)chainable. All four rendering overloads carry the same[SuppressMessage("ApiDesign", "RS0026...")](:247,:259,:288,:309): two public overloads with optional parameters is the shape RS0026 forbids, and the justification records them as grandfathered, released after the v1.152 public-API baseline while RS0026/RS0027 were off, so changing the signatures now would be a breaking change. - Branching on why.
HasErrorType(this Result, ErrorType)(:328-332) is the general predicate; the doc comment (:320-323) notes the category survives the HTTP round trip through ProblemDetailsResultReader, which is what makes this meaningful client-side at all.IsNotFound()(:340) andIsUnauthorized()(:348) are the two named cases, and their doc comments state the intended UI reaction: a 404 becomes a "not found" state rather than an error alert, a 401 becomes a redirect to the login route. - The private helpers.
LocalizeError(Error, IStringLocalizer?)(:350-373) chooses the lookup per error. With no localizer it returns the message verbatim. For a synthesized HTTP status (ProblemDetailsResultReader.TryGetSynthesizedStatus) it tries the status's ownHttp.{status}code, then theHttp.Statusformat with the status code as argument, then the message (:360-364). For the executor's own transport or timeout failure it tries the code, then the message (:367-369);IsSynthesizedTransportFailure(:400-405) matches only when the message is still the executor's own English sentence, so a caller that reused one of those codes with a message of its own keeps it. Anything else falls toLocalize.LookUpByCode(:377) asks the caller's localizer first and then the framework'sSharedResources, andFormatByCode(:386) does the same for a format string.Localize(string message, IStringLocalizer?)(:407-416) is where pass-through actually happens: a null localizer or a blank message returns the input unchanged, otherwise it indexes the localizer and returnslocalized.ResourceNotFound ? message : localized.Value(:415).
- Unwrapping.
Why it's built this way: extension methods on
Resultrather than an injectable service, because there is no state and nothing to resolve, so a page uses them without a constructor parameter and a unit test calls them directly. Every rendering helper returns the result it was given, which is what allows the fluent one-liner style the class doc advertises. The nullableIStringLocalizer?parameter everywhere means the helpers work from a context that has no localizer (they then render verbatim) rather than forcing one in.Where it's used: by every page and component that calls an IEntityService<TEntityDTO, TIdentifierType> member, across the framework package and both consumer apps, plus the shared deduplicating error-summary component. Covered by ResultUiExtensionsTests (
MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Common/ResultUiExtensionsTests.cs:17)[Rubric §28, Front-End Testing].Caveats / not-in-source: the class doc opens with "the Result transport (ADR-030)" (
ResultUiExtensions.cs:14), but ADR-030 is030-startup-sole-migrator.md. The Result-pattern record, including the 2026-08-27 revision that namesResultUiExtensionsand its exact member list, is ADR-013; the client data-access half is ADR-094. Trust the ADR index over the comment.
NotificationRoutePaths
MMCA.Common.UI ·
MMCA.Common.UI.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/NotificationRoutePaths.cs:8· Level 5 · class (static)
- What it is: the route constants for the framework's own notification feature, three literal paths plus one typed deep-link builder for a single inbox item.
- Depends on:
System.Globalization.CultureInfo(NotificationRoutePaths.cs:1) and theUserNotificationIdentifierTypealias, which resolves toint(MMCA.Common/Source/Core/MMCA.Common.Shared/GlobalUsings.NotificationIdentifierType.cs:2). That alias dependency is why an otherwise Level 0-looking constants class sits at Level 5. - Concept introduced, formatting a URL segment invariantly. The "one source of truth for route strings" idea is taught at RoutePaths; what is new here is the builder method and why it pins a culture.
[Rubric §27, Internationalization]assesses whether culture-sensitive formatting is applied deliberately rather than by default, and this is the case where the correct answer is to opt out. The doc comment (:14-18) spells it out: the route's:intconstraint is the validation boundary, so a culture that renders digit groups (1,234) or non-ASCII digits would produce a URL the constraint rejects. A route segment is machine-readable data, not user-facing text. - Walkthrough: three
public static readonly stringmembers and one method.Notifications = "/notifications"(:10), the admin push-management list.NotificationSend = "/notifications/send"(:11), the admin send page.NotificationInbox = "/notifications/inbox"(:12), the per-user inbox.NotificationInboxItem(UserNotificationIdentifierType id)(:21-22) composes the deep link withstring.Create(CultureInfo.InvariantCulture, $"{NotificationInbox}/{id}"). The target route exists:NotificationInbox.razorcarries both@page "/notifications/inbox"and@page "/notifications/inbox/{Id:int}"(MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Notifications/NotificationInbox.razor:1-2).
- Why it's built this way: kept separate from RoutePaths so an app that never enables the notification module carries no irrelevant constants, and so notification routes evolve on their own.
string.Createwith an explicit culture, rather than plain string interpolation, is the analyzer-friendly way to state "this formatting is deliberate" in a repo where every analyzer is an error. - Where it's used: the notification surfaces navigate by these constants rather than by literals:
NotificationSend.razor.cs:72(its breadcrumb back to the list),:117(post-send navigation) and:136(the cancel path),NotificationList.razor.cs:106(navigate to send), andNotificationBell.razor.cs:249(NavigateToInbox). NotificationUIModule builds its two NavItem entries fromNotificationInboxandNotifications(NotificationUIModule.cs:19-20). ADC'sAppActionRouteMapTestsasserts that a push action resolves toNotificationRoutePaths.NotificationInbox(MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.UI.Tests/Services/AppActionRouteMapTests.cs:39). - Caveats / not-in-source:
NotificationInboxItemhas no call site in any of the four repos as of this source; it is listed inPublicAPI.Unshipped.txt(MMCA.Common/Source/Presentation/MMCA.Common.UI/PublicAPI.Unshipped.txt:315, recorded there with its resolved signatureNotificationInboxItem(int id)), while the three string constants are baselined inPublicAPI.Shipped.txt:913-915. The{Id:int}route it targets is live; the typed builder for it is shipped but not yet adopted.
DependencyInjection
MMCA.Common.UI ·
MMCA.Common.UI·MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:31· Level 10 · class (static, with oneextension(IServiceCollection)block)
- What it is: the composition root of the UI layer. One
AddUIShared(configuration)call wires the shared UI infrastructure every head needs (Blazor Server, WebAssembly, MAUI), and six smaller methods cover the per-head, per-module and per-opt-in registrations. - Depends on: nearly the whole group. Settings: ApiSettings, LayoutSettings, LegalSettings, RegistrationSettings, UiReadCacheOptions, NotificationBellOptions. Authorization: NotificationPageRequirement / NotificationPageAuthorizationHandler. Caching: IUiReadCache / UiReadCache. Localization: PseudoStringLocalizerFactory, ResxMudLocalizer, InvariantMudLocalizationInterceptor. HTTP: AuthDelegatingHandler, CultureDelegatingHandler, SameOriginProxyRequestHandler, HttpResilienceDefaults. Facades: IToastService / MudToastService, IAppDialogService / MudAppDialogService. Services: IAuthUIService, IEmailConfirmationUIService / EmailConfirmationUIService, ILegalAcceptanceUIService / LegalAcceptanceUIService, ViewerTimeZone, OAuthFlowStateStore, ListPageStateService, ListPageQueryStateService, NavigationHistoryService, ThemeService, ICultureApplier / EndpointCultureApplier, IPublicLinkBuilder / NavigationPublicLinkBuilder, IUserPreferenceWriter, IUserPreferenceReader, IOAuthUISettings / DefaultOAuthUISettings, ISessionCookieSync / JsFetchSessionCookieSync. Administration opt-ins: IUserAdminUIService<TUserDto> / UserAdminService<TUserDto>, IUserAdminActionsUIService, IRoleAdminUIService / RoleAdminService. Capabilities: IFormFactor / WasmFormFactor. Composition: IUIModule, IEntityService<TEntityDTO, TIdentifierType>. Externals: Scrutor (
Decorate,Scan), MudBlazor, andMicrosoft.Extensions.{Configuration, DependencyInjection, Localization, Options}(DependencyInjection.cs:2-23). - Concept introduced, the composition root written as an
extension(T)block. The whole registration surface lives insideextension(IServiceCollection services)(DependencyInjection.cs:33) rather than as classicthis-parameter extension methods; see primer, C# extension(T) types for the language mechanics, taught once.[Rubric §15, Best Practices & Code Quality]assesses one consistent idiom across layers, and this file matches the otherDependencyInjectionclasses in every layer of the workspace.[Rubric §33, Developer Experience]assesses fail-fast startup and a small number of calls per host.[Rubric §12, Performance & Scalability]assesses whether concerns are wired once, centrally: localization, culture forwarding, authentication, resilience and caching are all configured here rather than per page. - Walkthrough: seven methods in the extension block.
AddUIShared(IConfiguration configuration)(:39-211), in order:- Options. ApiSettings binds with
.ValidateDataAnnotations().ValidateOnStart()(:42-46), so a missingApiEndpointfails the host at startup rather than at the first HTTP call. LayoutSettings binds without validation because empty defaults are acceptable (:48-49), and so do LegalSettings (:53-54, the footer, registration-checkbox and terms-dialog links; an absent section leaves every URL empty, which renders none of them) and RegistrationSettings (:58-59, the optional blocks of the shared register page; an absent section keeps every block). UiReadCacheOptions (:63-64) and NotificationBellOptions (:66-67) bind the client-side staleness policy; the comment (:61-62) records that both sections are optional and an absent section leaves the compiled-in defaults, which is what a host gets without configuring anything. - Clock and read cache.
TryAddSingleton(TimeProvider.System)(:71), with a comment explaining both directions of theTryAdd(:69-70): a host that already registered one, asAddInfrastructuredoes, keeps it, and a test substitutes aFakeTimeProvider.TryAddScoped<IUiReadCache, UiReadCache>()(:76) is scoped so it is per-circuit on Blazor Server; the comment (:73-75) records the consequence on the other heads, where the scope is the app lifetime, which is why the sign-out path clears it explicitly, otherwise one account's reads would outlive its session[Rubric §26, Front-End Security]. - Notification-page policy.
AddAuthorizationCoreregisters a policy namedNotificationPageRequirement.PolicyNamecarrying a NotificationPageRequirement (:81-83), andTryAddEnumerableadds the transient NotificationPageAuthorizationHandler (:84-85). The comment (:78-80) states the intent:[Authorize]semantics, except that a host hiding those pages (Layout:HideNotificationPagesWhenUnregistered) lets the request reach the router, which answers 404 instead of the sign-in challenge. - Localization (ADR-027).
AddLocalization()(:88) forIStringLocalizer<T>, thenDecorate<IStringLocalizerFactory, PseudoStringLocalizerFactory>()(:95), registered unconditionally because the pseudo-locale transform is inert under every other culture and the pseudo locale is only ever activatable in Development (:90-94).TryAddTransient<MudBlazor.MudLocalizer, ResxMudLocalizer>()(:101) localizes MudBlazor's own component text; the comment (:97-100) records whyTryAddis authoritative regardless of host registration order, namely thatAddMudServicesregisters noMudLocalizerof its own and a DI resolution test guards that assumption.AddLocalizationInterceptor<InvariantMudLocalizationInterceptor>()(:110) follows immediately: MudBlazor's own default interceptor reads its built-in English strings by assigningCultureInfo.CurrentUICulture(invariant, then the previous value back), which writes anAsyncLocalculture into the calling context; on a MAUI hybrid head, where that is the main thread, nothing resets it and the app pins to its launch language even after a culture switch reloads the thread defaults. InvariantMudLocalizationInterceptor reads the same resource under an explicit invariant culture instead, and registers with replace semantics so it wins whetherAddMudServicesran before or after this call (ADR-027 Decision 10) (:103-109). - The one HTTP client. Both delegating handlers register transient (
:114-115). Before the client itself,usesSameOriginProxyis computed from the raw configuration (:120-121): it is true only whenApi:SameOriginApiEndpointcarries a value, which per the comment (:117-119) happens only on a WebAssembly client whose Server host opted into the same-origin API proxy (ADR-131) and handed that value down through/client-config; every other host (Server, MAUI, a WASM client of a host that did not opt in) keeps the pipeline unchanged. Then the named"APIClient"(:124-147), whose builder is kept inapiClient(:124) so a handler can be appended afterwards. Its factory resolvesIOptions<ApiSettings>and setsclient.BaseAddress = new Uri(apiSettings.SameOriginApiEndpoint ?? apiSettings.ApiEndpoint!, UriKind.Absolute)(:131-136): the same-origin proxy base, when present, replaces the gateway URL for data calls, and the comment (:134-135) notes the bootstrap has already resolved it to an absolute address. There is deliberately no hand-written endpoint guard, and the comment says why (:126-130): resolving.Valueruns theValidateDataAnnotationsrules registered above, so a missing[Required]endpoint already fails as anOptionsValidationException, and a second check would only give the same failure a different, less informative exception.client.Timeoutis pinned to HttpResilienceDefaults.TotalRequestTimeout(:142) because the BCL's own 100-second default was chosen with no knowledge of the resilience budget and would cut a call off mid-policy at an arbitrary point (:138-141)[Rubric §29, Resilience & Business Continuity]. Default headers are cleared andAccept: application/jsonadded (:143-144), and the two handlers chain in order (:146-147) so every outgoing call carries both the bearer token and the active UI culture asAccept-Language. WhenusesSameOriginProxyis true, SameOriginProxyRequestHandler is registered transient and appended as the innermost handler (:149-155); the comment (:151-152) gives the reason for that position: it must see, and strip, everyAuthorizationheader the outer handlers or a service'sDefaultRequestHeadersattached, and it stamps the proxy's CSRF header[Rubric §26, Front-End Security]. - Facades.
services.AddCommonUiFacades()(:159), factored out so a bUnit harness can register exactly these two without pulling in the whole shared-UI surface (:157-158). - Scoped services, all via
TryAddso several composing hosts cannot double-register: IAuthUIService (:162), IEmailConfirmationUIService to EmailConfirmationUIService (:166, the/confirm-emailpage's client per ADR-116; a separate interface rather than newIAuthUIServicemembers, so a consumer's ownIAuthUIServiceimplementation keeps compiling, per the comment at:164-165), ILegalAcceptanceUIService to LegalAcceptanceUIService (:170, the terms-acceptance client behindTermsAcceptanceGate, registered for every host and inert until a host renders the gate, per the comment at:168-169), OAuthFlowStateStore (:174, binding an OAuth completion to the flow this client started so a deep-linked completion code from someone else's provider round trip is dropped instead of exchanged, per its own comment at:172-173), ListPageStateService (:175), ListPageQueryStateService (:176), NavigationHistoryService (:177), ThemeService (:180, ADR-028), ICultureApplier defaulting to EndpointCultureApplier (:186), ViewerTimeZone (:190, the viewer's browser time zone, so UTC instants such as notification and session times render on the clock the person reads), IPublicLinkBuilder defaulting to NavigationPublicLinkBuilder (:196), and the per-user preference writer and reader (:199-200), documented as best-effort and a no-op for an anonymous user (:198).TryAddSingleton<IOAuthUISettings, DefaultOAuthUISettings>()(:204) supplies a no-op default that a downstream app replaces. - Capabilities.
AddDeviceCapabilityDefaults()(:208, ADR-042) so every capability contract resolves on every head, registered here specifically so MAUI and browser hosts can override afterwards under last-registration-wins (:206-207).
- Options. ApiSettings binds with
AddCommonUiFacades()(:228-233): twoTryAddScopedcalls, IToastService to MudToastService (:230) and IAppDialogService to MudAppDialogService (:231). Its doc comment (:213-227) is the clearest statement of the facade rule anywhere in the codebase: apart from these two implementations, the only framework type that names MudBlazor'sISnackbar/IDialogServiceis theDeleteConfirmationcomponent, which injectsIDialogServicedirectly to show its typed dialog (pages, other components and theResulthelpers depend on the contracts), they are scoped to match the MudBlazor services they wrap, and the method is called both byAddUISharedand by the shipped bUnit base so a component test resolves the facades without the rest of the shared-UI surface.AddClientAuthSessionCookieSync()(:240-244): oneTryAddScoped<ISessionCookieSync, JsFetchSessionCookieSync>()(:242), the bridge that mirrors the client's in-memory tokens into the HttpOnly cookie read during server-side SSR prerender. Called from both the Blazor Server host and the WebAssembly client (:235-239).AddWasmFormFactor()(:252-253): registers IFormFactor to WasmFormFactor as a singleton. The doc comment names the two alternatives (:246-251):AddCommonWebFormFactor()fromMMCA.Common.UI.Webon the Blazor Server head,AddMauiFormFactor()fromMMCA.Common.UI.Mauion the MAUI head.AddUserAdministrationUI<TUserDto>()(:269-279), the ADR-116 UI opt-in for user administration: registers IUserAdminUIService<TUserDto> to UserAdminService<TUserDto> (:271), then forwards IUserAdminActionsUIService to the same instance rather than registering it a second time (:275-276), so the actions the component performs and the page it lists go through one instance (and one substitute, in a test), per the comment at:273-274. The doc comment (:255-266) states the call convention (once per app, afterAddUIShared, with the app's own administration DTO) and that an app which serves no administration endpoints registers nothing and the component the service backs is simply never rendered.AddRoleAdministrationUI()(:295-300): the non-generic ADR-116 sibling, registering IRoleAdminUIService to RoleAdminService (:297). Its doc comment (:281-293) explains why it takes no type parameter unlikeAddUserAdministrationUI<TUserDto>(): roles and permissions are strings the framework already owns, so there is no app DTO to name.AddUIModule<TModule>()(:320-330), constrained toTModule : class, IUIModule(:321): a Scrutor scanFromAssemblyOf<TModule>()registering every IEntityService<TEntityDTO, TIdentifierType> implementation scoped as its implemented interfaces (:323-327), thenAddSingleton<IUIModule, TModule>()(:329). The doc comment (:308-314) records the deliberate boundary: this is the two-step prologue every module'sAdd{Module}UI()opens with, and module-specific services stay with the caller afterwards, so a module whose service must beat a shared default still controls its own registration order. The type-parameter doc (:316-319) states the constraint that follows from using the descriptor's assembly as the scan root: it must live alongside the module's entity services and Razor pages.
- Why it's built this way:
TryAddthroughout is both a safety property (several composing hosts callingAddUISharedcannot double-register) and the override mechanism (a host that registers its own implementation before the call wins). Two ordering choices push in the opposite direction and are called out in comments because they are load-bearing: ICultureApplier's default round-trips a server/culture/setendpoint that a MAUI hybrid head does not have, so hybrids override it afterAddUIShared(:182-185), and IPublicLinkBuilder's default resolves against the browser origin, which is wrong for a MAUI WebView whose origin is a virtual host nobody else can open, so that is overridden after as well (:192-195). InvariantMudLocalizationInterceptor follows the same head-dependent pattern one layer down: MudBlazor's own interceptor is correct everywhere except the MAUI hybrid main thread, so the replacement is registered unconditionally here rather than left to the affected head to override, because replace semantics make the registration order irrelevant (:103-110). SameOriginProxyRequestHandler is the one registration decided by configuration rather than by order: it is added only whenApi:SameOriginApiEndpointis present (:120-121,:149-155), so a host that never opts in carries no trace of it in its pipeline. Read together, the file encodes a rule worth carrying into any new registration: a contract whose correct implementation depends on the head is defaulted here and replaced later (or, where replace semantics make order irrelevant, registered outright), while a contract that is the same everywhere isTryAdded and left alone. The two administration opt-ins follow the same principle one level up: nothing else in the framework calls either one, so an app that serves no administration or role-administration endpoints registers nothing and the corresponding component is simply never rendered. - Where it's used: called once at startup by all six consuming UI hosts (ADC's
MMCA.ADC.UI.Web,MMCA.ADC.UI.Web.Clientand MAUIMMCA.ADC.UI, plus the three Store equivalents), each followed by the per-moduleAdd{Module}UI()calls, which are usually one-liners overAddUIModule<TModule>(), for exampleMMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.UI/DependencyInjection.cs:19andMMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/DependencyInjection.cs:24.AddCommonUiFacades()has a second caller outside any host, the shipped bUnit base (MMCA.Common/Source/Hosting/MMCA.Common.Testing.UI/Infrastructure/BunitComponentTestBase.cs:54).AddUserAdministrationUI<TUserDto>()andAddRoleAdministrationUI()are each meant to be called once per app, afterAddUIShared, by an app that opts into the corresponding ADR-116 administration surface (:259,:286); nothing inside the framework itself calls either one. The"APIClient"configured here is the client every EntityServiceBase<TEntityDTO, TIdentifierType>-derived service resolves. The assembly this class lives in is also the one UISharedAssemblyReference names for the architecture fitness suite.
ApiFileDownloadButton
MMCA.Common.UI ·
MMCA.Common.UI.Components.Forms·MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/Forms/ApiFileDownloadButton.razor.cs:14· Level 0 · class (partial component)
- What it is: a payload-agnostic icon button that hands the user a file produced by an API endpoint. On a browser it is a plain download link to the endpoint; on a native (MAUI) head, where the WebView cannot download, it fetches the bytes over the API client, stages a temp file, and opens the OS share sheet. Callers supply the endpoint, the file name, the MIME type and the labels; the button knows nothing about what the payload is (
ApiFileDownloadButton.razor.cs:6-13). - Depends on:
IExternalLinkServiceandIShareService(the device-capability abstractions),IToastService,ApiSettingsthroughIOptions<ApiSettings>, andIStringLocalizer<ApiFileDownloadButton>over the component's own.resxpair, all injected in the markup file (Components/ApiFileDownloadButton.razor:4-9). Externals:IHttpClientFactory,System.IO.File/Path, and MudBlazor'sMudIconButton. - Concept introduced, one component, two heads, one contract.
[Rubric §18, UI Architecture](assesses whether components have a single clear responsibility and keep host differences out of pages) and[Rubric §26, Front-End Security](assesses whether the front end treats caller-supplied data as untrusted). The head split is decided by a capability query, not by a compilation symbol: the markup branches onExternalLink.InterceptsLinks(ApiFileDownloadButton.razor:18), and both branches render the sameMudIconButtonwith the same icon, size and accessible name, so the affordance looks identical while the mechanism differs (ADR-042). The security concept is path containment: a file name built from entity data must not be able to steer a filesystem write, which is whatResolveStagedFileNameexists to prevent. - Walkthrough:
- Parameters (lines 17-80). Three are
[EditorRequired]:RelativeApiPath(line 19),FileName(line 30) andShareTitle(line 35).ContentTypedefaults toapplication/octet-stream(line 43) and is what the share sheet uses to pick target apps.Icondefaults to the generic download glyph (line 47),SizetoSize.Small(line 51).AriaLabel,UnavailableMessageandFailureMessage(lines 59, 66, 73) are nullable overrides over localized defaults.HttpClientNamedefaults to"APIClient"(line 80), the framework's bearer-token plus culture-header client. AccessibleLabel(line 84) resolvesAriaLabel ?? L["Button.Download.Aria"].Value, and the markup binds it to BOTHaria-labelandtitleon either branch (ApiFileDownloadButton.razor:22-23,:31-32), so an icon-only control always carries a name. The default key lives in the component's own resource file (Components/ApiFileDownloadButton.resx:15).BrowserDownloadUrl(lines 93-103) is the browser branch'sHref. The anchor is a plain top-level navigation, so it carries cookies but never anAuthorizationheader (lines 86-92), and the base is chosen in that light asSameOriginApiEndpoint ?? WasmApiEndpoint ?? ApiEndpoint(line 98). On a WebAssembly client of a host running the same-origin API proxy the link targets the proxy, the same base the"APIClient"uses: the browser sends the HttpOnly session cookie and the proxy attaches the real bearer server-side, so an authenticated download works with no token in script (ADR-131). Everywhere else it prefersWasmApiEndpointoverApiEndpointbecause the browser needs the externally reachable gateway URL: on the Server headApiEndpointmay be a container-internal name, and on the WASM headApiEndpointis already the browser-reachable value fetched from/client-config. With no base URL configured it falls back to the relative path (lines 99-100); otherwise it composes an absolute URI (line 101).ShareDownloadedFileAsync(lines 105-154) is the native branch. It re-entrancy-guards on_isExportingand an empty path (lines 107-110), sanitizes the file name before the fetch so an unusable name costs no download (lines 115-121), creates the named client and pulls the bytes (lines 123-124), stages the file (line 126), and callsShare.ShareFileAsync(line 128), toasting a warning when no share surface accepted it (line 130). Three catch blocks all surface a toast rather than throwing:HttpRequestException(line 133),OperationCanceledException(line 137, which here is theHttpClienttimeout, not a disposal, since no token is passed), and a bareException(line 143) covering the staging write and the share sheet, because this runs in anOnClickcallback where an unhandled exception is fatal to a native host (lines 145-147). Thefinallyclears the guard (line 152).ResolveStagedFileName(lines 167-176) reduces the caller's name to a bare file name withPath.GetFileNameand rejects.and..(lines 169-175), returning null when nothing usable remains. The remarks (lines 160-166) state the exact hazard:Path.Combinediscards its first argument outright when the second is rooted, and..segments walk out of the temp root, so an unsanitized name would decide where the delete and the write land.StageFileAsync(lines 185-197) writes intoPath.GetTempPath()under the already-sanitized name (line 187), deleting any leftover first (lines 189-192) so a truncated previous copy is never shared. It deliberately does not delete after sharing: on Android the share intent returns as soon as it launches, so deleting would race the receiving app (lines 179-184).
- Parameters (lines 17-80). Three are
- Why it's built this way: the download mechanics are the part every consumer would otherwise re-implement per file type, and they are exactly the part that differs per head, so they belong in the framework behind a capability query (ADR-042). Keeping the wording out of the component (labels are parameters with localized fallbacks) is what lets one button serve a calendar file, an export, or a receipt without the framework knowing any of those words.
- Where it's used: ADC wraps it in a thin calendar affordance,
MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Components/AddToCalendarButton.razor:8-17, which suppliesContentType="text/calendar", the calendar glyph, and its own localized aria-label and failure messages while the download and share mechanics stay here. Covered byApiFileDownloadButtonTests. - Caveats / not-in-source: the doc comment on
AriaLabel(line 54) tags the icon-only accessible-name rule as "ADR-021", but ADR-021 in the current set is021-consumer-inbox-idempotency; the accessibility contract the rule belongs to is ADR-063. Which apps a native share sheet offers for a given MIME type is OS behavior and not determinable from this source.
IApiSettings
MMCA.Common.UI ·
MMCA.Common.UI.Common.Settings·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Settings/IApiSettings.cs:6· Level 0 · interface
- What it is: a two-property read-only view of the API configuration a UI host needs: the base URL it calls, and the base URL it hands to a browser.
- Depends on: nothing. Two
string?getters and no using directives. - Concept introduced, split-horizon endpoints.
[Rubric §7, Microservices Readiness](assesses whether a component copes with the topology it is deployed into rather than assuming one address space). A Blazor Web host and the browser it serves do not see the gateway the same way.ApiEndpoint(line 9) is what the server calls, which in production is a container-internal or service-discovery name that resolves nowhere in a browser.WasmApiEndpoint(lines 11-17) is the externally reachable URL served to the WebAssembly client through the/client-configendpoint. Splitting them lets the server take the faster internal path while the browser gets a name it can resolve, from one configuration section. - Walkthrough:
string? ApiEndpoint { get; }(line 9) andstring? WasmApiEndpoint { get; }(line 17). Both are nullable, because the interface itself imposes no requirement; the[Required]rule lives on the implementation (ApiSettings). - Why it's built this way: a read-only interface over an options class is the shape that lets a consumer state "I only read configuration" instead of taking a mutable settings object. It also documents the contract in one place while
ApiSettingscarries the binding and validation attributes. - Where it's used: implemented by
ApiSettings(Common/Settings/ApiSettings.cs:9). The two endpoint values are read throughIOptions<ApiSettings>at the/client-configendpoints and in the API client factory, not through this interface. - Caveats / not-in-source: no injection site resolves
IApiSettingstoday: a repo-wide search finds the interface only at its declaration and on theApiSettingsclass. The doc comment (line 15) saysWasmApiEndpoint"falls back toApiEndpointwhen null", but neither host's/client-configendpoint actually does that today: both ADC and Store serve it through the sharedMapClientConfigEndpoint(MMCA.Common.UI.Web), which throws anInvalidOperationExceptionnaming the missing key whenWasmApiEndpointis unset instead of substitutingApiEndpoint(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/ClientConfig/ClientConfigEndpointExtensions.cs:58-60, called fromMMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:265andMMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:247). The fail-closed behavior is now framework policy shared by both hosts, not a per-host divergence, and the doc comment's fallback claim is stale.
InfiniteScrollSentinel
MMCA.Common.UI ·
MMCA.Common.UI.Components.Lists·MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/Lists/InfiniteScrollSentinel.razor.cs:21· Level 0 · class (partial component)
- What it is: a bottom-of-list marker that raises
OnVisiblewhen it scrolls near the viewport, so the hosting page can fetch and append its next page. It owns the IntersectionObserver and nothing else: the item markup, the fetch, and the accumulated list stay with the page. - Depends on:
IJSRuntimeand the shared JS module_content/MMCA.Common.UI/infinite-scroll.js(wwwroot/infinite-scroll.js),DotNetObjectReference,ElementReference, and MudBlazor'sMudProgressCircularin the markup. Nothing first-party; it is a sibling ofMobileInfiniteScrollList<TItem>, which drives the same JS module. - Concept introduced, the observer as a child component so disposal is correct.
[Rubric §23, Front-End Performance](assesses render and network cost: paging on demand instead of loading everything, and detaching observers so they stop costing anything),[Rubric §21, Accessibility](assesses whether dynamic content changes are announced without hijacking focus) and[Rubric §18, UI Architecture]. The design point recorded in the doc comment (lines 14-19) is a lifecycle one: a page deriving fromDataGridListPageBase<TDto>cannot hook async disposal because itsDisposeAsyncis not virtual, whereas a child component is disposed by the renderer the moment the host stops rendering it. Rendering the sentinel only while more pages exist therefore makes "detach the observer" a rendering decision rather than a bookkeeping one, and a filter reset that refills the list gets a fresh instance with a fresh observer. - Walkthrough:
- Parameters:
OnVisible(line 26),IsLoading(line 29) which renders the inline progress row, andLoadingLabel(line 32), the localized accessible name for that row. - State (lines 34-44): a per-instance
_observerId(aGuid"N" string, line 34), the_sentinelRefelement reference, the imported_module, the_dotNetRefself-reference handed to JS, the_observingflag,_wasLoading(line 43, the previous render'sIsLoading), and_disposed. OnSentinelVisible()(lines 50-52) is the[JSInvokable]callback. Its name is fixed by the shared JS module, which calls it by string (lines 46-49). It returns immediately when disposed, otherwise marshals onto the renderer withInvokeAsyncbefore invokingOnVisible.OnAfterRenderAsync(lines 55-66) attaches the observer on the first render, and re-attaches it on the render where a load has just finished:loadJustFinishedis_wasLoading && !IsLoading && _observing(line 57),_wasLoadingis then updated (line 58), and either condition triggersAttachObserverAsync(line 60). The reason is recorded on the field (lines 40-42): an IntersectionObserver reports threshold crossings only, so a sentinel that is still inside the viewport when the appended page arrives would never fire again and the list would stall until the user scrolled away and back. Re-observing creates a fresh observer, which delivers a fresh initial entry.AttachObserverAsync(lines 106-121) imports the module lazily (lines 110-111), creates theDotNetObjectReferenceonce (line 112), callsobservewith the reference, the element and the id (line 113), and sets_observing(line 114). AJSDisconnectedExceptionis swallowed (lines 116-120): during prerendering or circuit teardown there is no JS to talk to, and the list simply stops at the pages already loaded rather than failing the render.DisposeAsync(lines 69-104) suppresses finalization, guards re-entry with_disposed, callsunobservewhen it was observing (lines 84-87), disposes the module (line 89), tolerates bothJSDisconnectedExceptionandJSException(lines 92-99), and disposes theDotNetObjectReferencein afinally(line 102) so the .NET side is released even if the JS side already went away.- The markup (
Components/InfiniteScrollSentinel.razor:4-13) is a singledivcarrying the element reference, with the progress row rendered only whileIsLoading. That row isrole="status" aria-live="polite" aria-busy="true"(line 9), matchingPageLoadingState's politeness so a screen reader hears that more items are loading without the announcement interrupting reading. - The JS side is deliberately tiny:
observedisconnects any prior observer for the id, creates anIntersectionObserverwithrootMargin: '200px'and invokesOnSentinelVisibleon intersection (wwwroot/infinite-scroll.js:3-14);unobservedisconnects and forgets the id (lines 16-22). The 200px margin is what makes the next page start loading slightly before the sentinel is on screen, and the disconnect-before-create step is what makes the post-load re-attach above safe to call repeatedly with the same id.
- Parameters:
- Why it's built this way: extracting just the observer is what lets a page keep its own cards, empty state and error state and still get infinite scroll (lines 10-13). The alternative, folding the behavior into the list component, would force any page that wants infinite scroll to also adopt that component's layout.
- Where it's used: ADC's public speaker list renders it below the card grid while more pages exist, wiring
OnVisibleto its own loader and passing a localized loading label (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/Speakers/PublicSpeakerList.razor:144-145, insidePublicSpeakerList). Covered byInfiniteScrollSentinelTests.
LayoutSettings
MMCA.Common.UI ·
MMCA.Common.UI.Common.Settings·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Settings/LayoutSettings.cs:9· Level 0 · class (sealed)
- What it is: the five settings that make the shared shell look and behave like a specific application: the navbar brand text, an optional brand logo URL, the footer text, an optional role gate on the framework-owned "signed-in devices" nav entry, and an opt-in switch that hides the framework's notification pages in a host that never registered the notification UI.
- Depends on: nothing first-party.
System.Diagnostics.CodeAnalysis.SuppressMessage(BCL) for one analyzer waiver, and theMicrosoft.Extensions.Optionsbinder at registration time. - Concept introduced, a settings section as a bound options class.
[Rubric §17, DevOps & Deployment](assesses whether configuration is centralized and typed rather than read ad hoc) and[Rubric §20, Design System and Theming](assesses whether the look of the app is expressed once rather than repeated per page). The shape repeats across every settings class in this namespace: apublic static readonly string SectionNamenaming the configuration section (line 12),init-only properties with compiled-in defaults, and oneservices.AddOptions<T>().Bind(configuration.GetSection(T.SectionName))call inAddUIShared(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:48-49). Because every property has a default, an absent section is not an error: the host simply gets the compiled-in values. Components then takeIOptions<LayoutSettings>and read.Value, so nothing in the shell parses configuration itself. - Walkthrough:
SectionName = "Layout"(line 12).BrandName(line 15) defaults to"MMCA".NavMenurenders it as the brand link's text and folds it into the link's localized accessible name (Layout/NavMenu.razor:18,:26).FooterText(line 18) defaults tostring.Empty, andMainLayoutrenders the footer block only when it is non-blank (Layout/MainLayout.razor:72-76). An empty default therefore means "no footer", not "an empty footer".BrandLogoUrl(line 30) defaults to empty, which renders the text-only brand. When set,NavMenuemits animgbeside the brand text withalt=""andaria-hidden="true"(Layout/NavMenu.razor:22-24): the image is decorative because the brand link already carries its own accessible name, so alt text here would only repeat it to a screen reader (lines 20-24). The property carries a[SuppressMessage]for CA1056 (lines 26-29) whose justification records why it is astringand not aUri: the value is usually a host-relative path such as/img/logo.svg, whichSystem.Uricannot represent withoutRelativeOrAbsoluteround-tripping.SessionsNavRequiredRole(line 39) is nullable and unset by default, which shows the "signed-in devices" nav entry to every signed-in user.NavMenureads it inOnInitializedAsyncand computes_showSessionsLinkas unset-or-blank OR the current user is in that role (Layout/NavMenu.razor:214-215). The gate covers only the menu entry:/profile/sessionsitself stays reachable for any signed-in account whether or not the link is shown (Layout/NavMenu.razor:78-82).HideNotificationPagesWhenUnregistered(line 49) defaults tofalse, which leaves the routes exactly as they are. Whentrue, the framework's notification pages (/notifications,/notifications/inbox,/notifications/inbox/{Id}and/notifications/send) answer with the not-found page in a host that never calledAddNotificationUI(), instead of being reachable by URL with none of the services they need (lines 41-48). The decision isNotificationPageGate.Hides, which requires the flag, a page type in its notification set, and no registeredNotificationUIModule(MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/NotificationPageGate.cs:22-31), so a host that registers the notification UI is unaffected either way.
- Why it's built this way: the shell ships in the framework package, so the only way a consuming app can brand it, narrow a framework-owned menu entry, or close framework-owned routes it does not use, without forking is configuration. Keeping the branding in
appsettings.jsonalso means a deployment can rebrand without a rebuild. - Where it's used: injected as
IOptions<LayoutSettings>byLayout/NavMenu.razor:12andLayout/MainLayout.razor:11, and read byNotificationPageGate(Notifications/NotificationPageGate.cs:22). Configured by every UI host, for exampleMMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/appsettings.json:15-18. Covered byNavMenuTestsandNotificationPageGateTests.
LegalSettings
MMCA.Common.UI ·
MMCA.Common.UI.Common.Settings·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Settings/LegalSettings.cs:16· Level 0 · class (sealed)
- What it is: the four legal-document URLs a host can publish (Terms of Service, Privacy Policy, Code of Conduct, and the page explaining how to delete an account), plus a computed flag saying whether any footer document is configured. Every URL defaults to empty, which means "not configured" and hides the link it drives.
- Depends on: nothing first-party.
System.Diagnostics.CodeAnalysis.SuppressMessage(BCL) for the CA1056 waivers, and theMicrosoft.Extensions.Optionsbinder at registration time. - Concept introduced, an empty default as the off switch.
[Rubric §17, DevOps & Deployment](assesses whether configuration is centralized and typed rather than read ad hoc). It follows the same bound-options shape asLayoutSettings: apublic static readonly string SectionName = "Legal"(line 22) andinit-only properties with compiled-in defaults. Here the default doubles as the feature toggle: an unset URL isstring.Empty, and each consumer testsstring.IsNullOrWhiteSpacebefore rendering a link, so a host opts into each document by configuring it and a host that configures nothing sees no legal UI at all. The section is optional for the same reason; ADR-070 (fail-fast configuration contract) is the reference for which settings must be present and which, like this one, may be absent. - Walkthrough:
TermsUrl(line 26),PrivacyUrl(line 30) andCodeOfConductUrl(line 34) arestringproperties defaulting tostring.Empty. The doc comment onTermsUrlstates the double effect: empty hides both the footer link and the registration checkbox (line 24).DeleteAccountUrl(line 41) is the page that explains how to delete an account, which app stores require for apps that offer sign-up (lines 36-39). It is deliberately outside the footer group.- Each URL carries a
[SuppressMessage]for CA1056 (lines 25, 29, 33, 40) sharing oneUrlJustificationconstant (lines 18-19): the value is bound from configuration and emitted straight into anhref, and the empty default is not a validSystem.Uri, the same precedent asLayoutSettings.BrandLogoUrl. HasFooterLinks(lines 44-47) is true when any of the three footer documents (Terms, Privacy, Code of Conduct) is non-blank.DeleteAccountUrlis not part of it.
- Why it's built this way: the shared shell and the register page ship in the framework package, so the only way an app can publish its own legal documents without forking is configuration. Computing
HasFooterLinksonce on the settings class keeps the "is there anything to show" decision out of every markup file. - Where it's used: bound by
services.AddOptions<LegalSettings>().Bind(...)inMMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:53-54.Layout/MainLayout.razorinjects it (line 13), renders the footer when footer text orHasFooterLinksis present (line 87), and emits oneExternalLinkper configured document (lines 104-114).Components/Legal/TermsAcceptanceGate.razor.csreads it throughIOptions<LegalSettings>(lines 70, 78) and the gate markup checksLegal.HasFooterLinks(TermsAcceptanceGate.razor:17).Pages/Auth/Register.razorinjects it (line 17) and shows the Terms checkbox only when a Terms URL is configured (line 143;Pages/Auth/RegisterModel.cs:46). ADC'sLegalAndDataCard.razor.cson the profile page reads it too. Covered byMainLayoutFooterTestsandRegisterTermsTestsinMMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests.
MmcaClientConfigBootstrap
MMCA.Common.UI ·
MMCA.Common.UI.Common.Settings·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Settings/MmcaClientConfigBootstrap.cs:28· Level 2 · class (static)
- What it is: the WebAssembly client's bootstrap-time fetch of the
client-configdocument (served byClientConfigEndpointExtensions.MapClientConfigEndpoint) into a streamAddJsonStreamcan consume, with one automatic retry on failure or timeout, and with a same-origin API proxy path resolved to an absolute address on the way through. - Depends on:
ApiSettings, used only for itsSectionNameand theSameOriginApiEndpointproperty name when rewriting the document; otherwiseSystem.Net.Http.HttpClient,System.Text.Json.NodesandSystem.Text.Encoding(BCL). Nothing it touches needs a service provider, which is deliberate, since it runs beforebuilder.Servicesexists. - Concept introduced, a bounded retry at the one point in startup where configuration itself is still unknown.
[Rubric §29, Resilience, Reliability & Business Continuity](assesses whether a single transient failure at startup is survivable) and[Rubric §17, DevOps & Deployment]. Every other resilience mechanism in the framework (Polly pipelines, the outbox, internal commands) runs once DI and configuration exist; this runs before both, inProgram.cs, fetching the very document that will configure the rest of the client. APolly-based retry is unavailable at this point (no service provider yet), so the retry is hand-rolled: one attempt, then one retry after a fixed delay, with the caller's ownHttpClientand its ownTimeout. - Walkthrough:
ClientConfigPath = "client-config"(line 31), the relative path both overloads fetch, matching the endpoint's own route.DefaultTimeout(line 34, 15 seconds) andDefaultRetryDelay(line 37, 1 second) are the defaults the single-argument overload uses.LoadAsync(Uri baseAddress, CancellationToken)(lines 49-60) is the convenience overload: it builds a short-livedHttpClientscoped tobaseAddresswithDefaultTimeout, then delegates to the caller-owned overload. It carries an RS0027[SuppressMessage](line 48) whose justification records it as a grandfathered public overload whose signature cannot change without a breaking change.LoadAsync(HttpClient client, TimeSpan retryDelay, CancellationToken)(lines 72-90) is the real logic: fetch the bytes (line 81), and onHttpRequestExceptionor a caller-uncancelled timeout (IsTimeout, lines 83, 142-143) waitretryDelaythen fetch once more (lines 85-86) with no further retry.IsTimeout(lines 142-143) is the detail that makes the retry precise:HttpClientreports its own per-request timeout asTaskCanceledException, the same exception type the caller's own cancellation produces, so the two are told apart by checking whether the caller's token was the one that fired; only a timeout the caller did not ask for is retried.- The result passes through
ResolveSameOriginApiEndpointagainst the client'sBaseAddressand is wrapped in a non-writableMemoryStream(line 89), the shapeAddJsonStreamexpects. ResolveSameOriginApiEndpoint(byte[], Uri?)(lines 98-128,internal) exists because a host running the same-origin API proxy servesApi:SameOriginApiEndpointas an origin-relative path (the server cannot know the public origin a browser used behind ingress) while the"APIClient"needs an absolute base address (lines 92-97). It returns the document byte for byte unless every precondition holds: an absolute base address (line 100), a document that parses (aJsonExceptionis left for configuration binding to report, lines 110-114), anApiobject holding a non-blank stringSameOriginApiEndpointthat is not already an http(s) URL (lines 116-124). Only then does it replace the value withnew Uri(baseAddress, path).AbsoluteUriand re-serialize (lines 126-127). Parsing is case-insensitive on property names (line 108).IsHttpAbsolute(string)(lines 136-138,internal) accepts only an absolutehttporhttpsURI. A bare absolute-Uri.TryCreatecheck is not enough: on Unix-like runtimes, browser WebAssembly included,"/api/"parses as the absolutefile:///api/, so the relative path would be left unresolved and every client call would go tofile:///api/...(lines 130-135).
- Why it's built this way: a single flaky fetch (a cold container, a brief network blip during a rollout) would otherwise fail the entire WASM client boot before it can render anything, including an error page with any styling. One retry buys resilience against exactly that class of transient failure without turning a genuine outage into a long hang, since both attempts still respect
DefaultTimeout. Resolving the proxy path here rather than on the server keeps the server ignorant of the browser-facing origin, and doing it on the raw document means every later consumer ofApiSettingson the client sees an absolute address (ADR-131). - Where it's used:
MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web.Client/Program.cscallsLoadAsyncto populate the WASM client's configuration before any service is registered. The endpoint it fetches from isClientConfigEndpointExtensions(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/ClientConfig/ClientConfigEndpointExtensions.cs). Covered byMmcaClientConfigBootstrapTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Common/Settings/MmcaClientConfigBootstrapTests.cs) andSameOriginProxyClientTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/SameOriginProxyClientTests.cs). - Caveats / not-in-source: only one retry is ever attempted regardless of how the second attempt fails; a second transient failure fails the boot outright.
NotificationBellOptions
MMCA.Common.UI ·
MMCA.Common.UI.Common.Settings·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Settings/NotificationBellOptions.cs:12· Level 0 · class (sealed)
- What it is: the two numbers that define how stale the unread-notification badge is allowed to be: how often it re-reads the API, and how old its count may be before a navigation re-reads it.
- Depends on: nothing first-party.
TimeSpan(BCL). Bound with the same options shapeLayoutSettingsintroduces. - Concept introduced, staleness as a stated policy.
[Rubric §19, State Management](assesses whether client-side state has an explicit freshness contract) and[Rubric §31, Cost and FinOps](assesses whether the design lets an operator trade money against latency). Both numbers are host decisions rather than compiled constants: a deployment paying per API call widens the poll, one where an unread count must feel instant narrows it (lines 6-10). The framing in the doc comment is important for reading the code: the periodic read is the backstop behind the real-time push, not the primary path, soPollIntervalis the budget for "how long a missed push may go unnoticed" (lines 17-22). - Walkthrough:
SectionName = "NotificationBell"(line 15).PollInterval(line 23), default 30 seconds.NotificationBellbuilds itsPeriodicTimerfrom it (Components/Notifications/NotificationBell.razor.cs:103), against the injected clock rather than the ambient one. Zero or a negative value disables periodic polling while the push refresh and the navigation refresh keep working (lines 20-21): the bell returns before creating the timer whenPollInterval <= TimeSpan.Zero(NotificationBell.razor.cs:96-99), becausePeriodicTimerrejects such a period and throwing there faulted the circuit (lines 94-95).NavigationRefreshMaxAge(line 30), default 30 seconds. On a page change the bell accepts the count it already holds unless it is older than this window (NotificationBell.razor.cs:166, viaState.IsStale(...)). That is what keeps a user clicking through five pages in ten seconds from issuing five reads of a number that has not moved (lines 25-29).
- Why it's built this way: both values are pure policy with no correct universal answer, so they belong in configuration; and because both have defaults, a host that says nothing keeps the framework's chosen 30-second budgets.
- Where it's used: bound in
AddUIShared(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:66-67) and injected asIOptions<NotificationBellOptions>byNotificationBell(Components/Notifications/NotificationBell.razor.cs:36). Covered indirectly byNotificationBellTests.
QrErrorCorrectionLevel
MMCA.Common.UI ·
MMCA.Common.UI.Components.Sharing·MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/Sharing/QrErrorCorrectionLevel.cs:9· Level 0 · enum
- What it is: the four QR error-correction strengths the framework's QR components expose. Higher levels survive more damage or occlusion but pack fewer characters into the same module count, so the code grows denser (lines 4-5).
- Depends on: nothing. It is a bare enum with no using directives.
- Concept introduced, a framework-owned enum instead of a re-exported vendor type.
[Rubric §9, API and Contract Design](assesses whether a public surface is expressed in types the owner controls) and[Rubric §32, Dependency and Supply-Chain](assesses whether third-party types leak into contracts consumers must compile against). The doc comment states the decision outright (lines 6-7): declaring this rather than exposing QRCoder's ownECCLevelkeeps the component's public API from pinning consumers to the encoder package. The mapping to the vendor type is a private detail of the component, a one-lineswitchinComponents/QrCodeImage.razor:77-82, so replacing the encoder would not be a breaking change for any page that names this enum. - Walkthrough: four members with explicit values and a stated recovery budget each:
Low = 0(line 12, about 7% recovery, densest code, short payloads on clean screens),Medium = 1(line 15, about 15%, the usual screen and print trade-off),Quartile = 2(line 18, about 25%, printed sheets that may get scuffed) andHigh = 3(line 21, about 30%, codes overlaid with a logo or scanned in poor light). The explicit values matter because the enum is bound as a component parameter and compared for change detection. - Why it's built this way: the recovery percentages are properties of the QR standard, not of the encoder, so documenting them on a framework enum keeps the decision (how much damage must this code survive?) at the call site where the physical context is known.
- Where it's used:
QrCodeImagetakes it as a parameter defaulting toMedium(Components/QrCodeImage.razor:36) and maps it toQRCodeGenerator.ECCLevelbefore encoding (:77-82);QrCodeButtondefaults toQuartile(Components/QrCodeButton.razor:65). ADC passesMediumexplicitly on the attendee badge (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/CheckIns/MyBadge.razor:36) and the speaker QR page (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speakers/SpeakerQr.razor:29). Covered byQrCodeImageTests.
RatingStars
MMCA.Common.UI ·
MMCA.Common.UI.Components.Ratings·MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/Ratings/RatingStars.razor.cs:13· Level 0 · class (partial component)
- What it is: a five-icon star rating display over a
doublevalue on a 0-5 scale, rendering full, half, or empty stars per position. - Depends on: MudBlazor's
Sizeenum andIcons.Material.Filled.{Star,StarHalf,StarBorder}. Nothing first-party. - Concept introduced, a pure display component with no state of its own.
[Rubric §18, UI Architecture](assesses whether a component's responsibility is genuinely narrow) and[Rubric §21, Accessibility](assesses whether a visual-only rendering carries an equivalent text alternative). Unlike an editable star-rating input, this component only renders: it has no click handler and no two-way binding, so the sameValuealways paints the same five icons regardless of interaction history. The single requiredAriaLabelparameter is what keeps the rating accessible to a screen reader, which otherwise would see five decorative icons and no number. - Walkthrough:
MaxValue = 5(line 115) is the fixed scale; the markup renders exactly that many icons regardless ofValue.Value(line 127) is adoubleon the 0-MaxValuescale. The doc comment (lines 121-125) states the intended caller pattern explicitly: a caller holding adecimalaverage casts it todouble, because the half-star threshold this component applies needs no decimal precision.AriaLabel(lines 130-132) is[EditorRequired], so the compiler enforces that every usage supplies the accessible name for the whole rating; there is no per-star label.Size(line 136) defaults toSize.Small.FillFor(int star)(lines 145-153) is the per-position logic: a star index at or belowValueisFullStar; otherwise it isHalfStarwhenValueis within half a star of that position (Value >= star - 0.5), elseEmptyStar.IconFor(string fill)(lines 138-143) maps those three string constants to the three MudBlazor icon glyphs through aswitch, defaulting to the empty-star border icon for any other value.
- Why it's built this way: three private string constants (
FullStar/HalfStar/EmptyStar) rather than an enum keep the fill/icon mapping a two-function, easily-read pair; the component stays a pure function ofValuewith no fields to maintain across renders. - Where it's used: exercised directly by
RatingStarsTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Components/Ratings/RatingStarsTests.cs) and by 4 further call sites in Store's Catalog UI renderingProduct.RatingSummaryon catalog and product-detail pages. - Caveats / not-in-source: the exact call sites beyond the test project were not opened for this section (the brief lists "5" and "and 4 more"); which pages render it is not itemized here.
RegistrationSettings
MMCA.Common.UI ·
MMCA.Common.UI.Common.Settings·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Settings/RegistrationSettings.cs:8· Level 0 · class (sealed)
- What it is: a one-property settings class that lets a host turn the register page's optional postal-address block on or off.
- Depends on: nothing first-party. The
Microsoft.Extensions.Optionsbinder at registration time. - Concept introduced, a feature switch that defaults to the framework's existing behavior.
[Rubric §17, DevOps & Deployment](assesses whether configuration is centralized and typed rather than read ad hoc).CollectAddressdefaults totrue(line 18), so a host that never configures theRegistrationsection (SectionName, line 11) keeps the address block it always had; only a host that setsfalsechanges anything. ADR-070 (fail-fast configuration contract) covers why an absent optional section is not an error. - Walkthrough:
CollectAddress(line 18,get; set;) is the only member.trueshows the optional address block;falsehides it and the registration request carries no address (lines 13-17). - Why it's built this way: not every deployment wants to ask for a postal address at sign-up, and the register page is framework-owned, so configuration is the only way a consuming app can drop the block without forking the page.
- Where it's used: bound in
MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:58-59.Pages/Auth/Register.razorinjectsIOptions<RegistrationSettings>(line 18) and wraps the address block in@if (CollectAddress)(line 107);Pages/Auth/Register.razor.csexposesCollectAddressfrom the options (line 13) and branches on it at line 24. Covered byRegisterAddressOptionTestsinMMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Auth.
UIModuleConfiguration
MMCA.Common.UI ·
MMCA.Common.UI.Common.Settings·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Settings/UIModuleConfiguration.cs:10· Level 0 · class (static)
- What it is: a one-method helper that answers "is this UI module enabled in this host?" by reading
Modules:{moduleName}:Enabledfrom configuration, defaulting to enabled when nothing is configured. - Depends on:
Microsoft.Extensions.Configuration.IConfiguration(GetSection,Exists,GetValue). Nothing first-party, which is what lets a host call it before any DI registration has happened. - Concept introduced, composing a UI host from configuration.
[Rubric §7, Microservices Readiness](assesses whether the same codebase can be deployed as different subsets) and[Rubric §17, DevOps & Deployment]. The server-side module system registersIModuleimplementations in topological order; the UI side has its own analogue,IUIModule, registered by each module'sAddXUI()extension (ADR-067). This helper is the gate in front of those calls: a host that switches a module off never callsAddXUI(), so noIUIModuledescriptor is registered, and the shell composes without that module's routes, nav entries or services. The default-on behavior (lines 7-8) is a compatibility choice: a host with noModulessection behaves exactly as it did before the section existed. - Walkthrough:
ModulesSectionName = "Modules"(line 12) andIsModuleEnabled(IConfiguration configuration, string moduleName)(lines 18-22). It walks two section levels,Modulesthen the module name (line 20), and returns!section.Exists() || section.GetValue("Enabled", true)(line 21). Read carefully, that is two independent defaults: an absent module entry is enabled, and a present entry missing theEnabledkey is also enabled. Only an explicitfalseturns a module off. - Why it's built this way: a static helper over
IConfiguration(rather than a bound options class) is what makes it usable at the exact point it is needed, insideProgram.cs/MauiProgram.csbefore the service provider exists. Keeping the check in the framework rather than hand-rollingbuilder.Configuration["Modules:X:Enabled"]per host is what keeps the default-on semantics identical across all six heads. - Where it's used: all six UI hosts gate their module registrations with it. ADC:
MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:167-177,MMCA.ADC.UI.Web.Client/Program.cs:55-64,MMCA.ADC.UI/MauiProgram.cs:129-138(Identity, Conference, Engagement, Notification). Store:MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:120-126,MMCA.Store.UI.Web.Client/Program.cs:51-57,MMCA.Store.UI/MauiProgram.cs:83-89(Catalog, Sales, Identity). The corresponding configuration block isMMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/appsettings.json:9-14.
UiReadCacheOptions
MMCA.Common.UI ·
MMCA.Common.UI.Common.Settings·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Settings/UiReadCacheOptions.cs:13· Level 0 · class (sealed)
- What it is: the client-side staleness policy for
IUiReadCache: a master on/off switch, a default freshness budget, and per-route-prefix overrides. - Depends on: nothing first-party.
TimeSpanandDictionary<string, TimeSpan>(BCL), plus the options binder at registration. - Concept introduced, client-side freshness as a recorded decision.
[Rubric §19, State Management](assesses whether cached client state has an explicit lifetime),[Rubric §12, Performance and Scalability]and[Rubric §31, Cost and FinOps]. The doc comment states the intent precisely (lines 6-11): the point of writing staleness into configuration is that it becomes a decision a host records, rather than an accident of how often a component happens to re-render. This is the client-side analogue of the server caching strategy (ADR-026) applied to the entity data-access contract (ADR-094). - Walkthrough:
SectionName = "UiReadCache"(line 16).Enabled(line 24), defaulttrue. Setting it false turns every lookup into a miss and every store into a no-op (lines 19-22), so the services behave exactly as they would with no cache registered. The cache honors it on both paths (Services/Caching/UiReadCache.cs:36,:74), which is the framework's escape hatch for a host that wants no client-side staleness at all.DefaultTtl(line 32), default 60 seconds, applied to any read whose URL matches no configured prefix. The comment records the reasoning for the number (lines 27-31): short enough that a stale list corrects itself within one user's attention span, long enough to collapse the burst of identical reads a page issues while it mounts.RoutePrefixTtls(line 41), a getter-onlyDictionary<string, TimeSpan>keyed by the leading part of a relative URL (for examplecountries). Getter-only is deliberate: the configuration binder populates the instance the defaults created, which is how bindable collections are shaped across this namespace (lines 37-39). The longest matching prefix wins, so a specific child route can state a stricter budget than the endpoint above it whatever order configuration enumerates in; that resolution is implemented inUiReadCache.ResolveTtl(Services/Caching/UiReadCache.cs:120-135).
- Why it's built this way: a single global TTL would force one budget on reference data that changes hourly and on lists that change constantly, so the per-prefix table is what makes one cache usable for both. Longest-match rather than first-match removes any dependence on configuration ordering, which JSON does not guarantee.
- Where it's used: bound in
AddUIShared(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:63-64) and injected intoUiReadCache(Services/Caching/UiReadCache.cs:18, snapshotted to a field at:27). Covered byUiReadCacheTests.
WebApplicationExtensions
MMCA.Common.UI ·
MMCA.Common.UI.Extensions·MMCA.Common/Source/Presentation/MMCA.Common.UI/Extensions/WebApplicationExtensions.cs:8· Level 0 · class (static)
- What it is: a one-method middleware extension for Blazor Server / WASM hybrid hosts.
UseAuthenticatedNoStore()emitsCache-Control: no-storeon HTML responses to authenticated users, so a logged-out user pressing Back never sees the previous logged-in HTML. - Depends on:
Microsoft.AspNetCore.Builder.IApplicationBuilder,HttpContext.User, andHttpResponse.OnStarting(ASP.NET Core). Nothing first-party. - Concept introduced, the browser back-forward cache (bfcache) as an auth-leak boundary.
[Rubric §26, Front-End Security](assesses whether the front end avoids leaking authenticated content and treats the browser as hostile storage) and[Rubric §23, Front-End Performance](assesses render and navigation cost; bfcache is a performance feature this deliberately gives up, but only where it is unsafe). A browser's bfcache restores a full DOM snapshot of a previous page on Back without issuing a request, so no server authorization check runs. Emittingno-storeon a response makes that page bfcache-ineligible: Back re-requests it and the server re-renders under the current (possibly signed-out) identity. The scoping is the interesting part: anonymous pages keep their bfcache eligibility because the guard iscontext.User.Identity?.IsAuthenticated is true(line 30), and non-HTML responses (JSON, static assets, the Blazor framework files) are skipped by thetext/htmlcontent-type check (lines 31-32), so nothing but authenticated pages pays the cost. - Walkthrough: a static class holding a single C#
extension(IApplicationBuilder app)block (line 10), the sameextension(T)preview syntax the framework uses for DI registration (see primer).UseAuthenticatedNoStore()(lines 24-44) registers an inlineapp.Use((context, next) => ...)middleware (line 26).- It does not inspect the response at request time: it hooks
context.Response.OnStarting(line 28), the callback the server invokes just before the first byte of the response is written. That is what makes readingcontext.Userandcontext.Response.ContentTypemeaningful, both are populated by then even though this middleware sits ahead of the authentication middleware in the pipeline. - When both conditions hold it sets
Cache-Control: no-store, no-cache, must-revalidate, max-age=0plus the HTTP/1.0-eraPragma: no-cache(lines 34-35), then returnsTask.CompletedTask(line 37). - The middleware returns
next()immediately (line 40), and the extension returnsapp(line 43) so it chains in the usualapp.UseX().UseY()shape.
- Why it's built this way: an
IApplicationBuilderextension is the idiomatic ASP.NET Core registration shape, and theOnStartinghook is what allows a single narrow registration to make an after-the-fact decision (was this response authenticated? was it HTML?) instead of duplicating the check at every page. The remarks (lines 19-23) state the one ordering constraint: register it beforeMapRazorComponentsso it wraps every page response. - Where it's used: both Blazor Web hosts call it once:
MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:245andMMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:171. - Caveats / not-in-source: whether a given browser honors
no-storeas bfcache-ineligibility is browser behavior, not code, and cannot be verified from this source. This type is distinct from the same-namedWebApplicationExtensionsin the API layer; they share a name across assemblies, not an implementation.
ApiSettings
MMCA.Common.UI ·
MMCA.Common.UI.Common.Settings·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Settings/ApiSettings.cs:9· Level 1 · class (sealed)
- What it is: the bound implementation of
IApiSettings: the"Api"configuration section, validated at startup so a host with no API endpoint fails immediately instead of at the first request. - Depends on:
IApiSettings(the read-only contract it implements) andSystem.ComponentModel.DataAnnotations.RequiredAttribute(BCL). - Concept introduced, fail-fast configuration.
[Rubric §29, Resilience, Reliability & Business Continuity]and[Rubric §15, Best Practices and Code Quality]. The class is three properties of data, but the behavior lives in how it is registered:AddOptions<ApiSettings>().Bind(...).ValidateDataAnnotations().ValidateOnStart()(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:42-45).ValidateDataAnnotationsturns the[Required]attribute into an options validator, andValidateOnStartruns that validator during host startup rather than lazily on first resolution, so a missingApi:ApiEndpointsurfaces as anOptionsValidationExceptionnaming the key before the host accepts traffic (ADR-070). That is what licenses the null-forgivingapiSettings.ApiEndpoint!in the client factory (MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:133-136): the validator, not a local check, is the guarantee. - Walkthrough:
SectionName = "Api"(line 12), the same convention every settings class here uses.[Required] public string? ApiEndpoint { get; init; }(lines 15-16). Nullable so the binder can leave it unset,[Required]so leaving it unset fails validation.init-only, so a bound instance is immutable after construction.WasmApiEndpoint { get; init; }(line 19) carries<inheritdoc />and no[Required]: it is optional at the contract level, and each host decides whether an absent value is acceptable.SameOriginApiEndpoint { get; init; }(line 33) is declared on the class only, not onIApiSettings, and isnulleverywhere except on a WebAssembly client whose Server host opted in withAddCommonSameOriginApiProxy(lines 21-32). The host serves it as an origin-relative path in/client-configandMmcaClientConfigBootstrap.LoadAsyncresolves it to an absolute address. When it is set, the"APIClient"and the notification hub target it instead ofApiEndpoint, send noAuthorizationheader (the proxy attaches the bearer from the HttpOnly session cookie server-side) and add the proxy's CSRF header;ApiEndpointkeeps the gateway URL for full-page navigations that must reach the gateway itself, such as the external sign-in challenge (ADR-131). In the client factory the switch isclient.BaseAddress = new Uri(apiSettings.SameOriginApiEndpoint ?? apiSettings.ApiEndpoint!, ...)(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:136), and the same configuration key decides at registration time whether the proxy pipeline is wired at all (DependencyInjection.cs:117-121).
- Why it's built this way:
sealedplusinitgives an immutable snapshot of configuration that cannot drift while the app runs. Putting the validation attribute on the options class rather than writing a guard in theHttpClientfactory keeps one failure mode with one message: the comment atMMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:126-130records that a second hand-written check would only give the same failure a different, less informative exception. - Where it's used: bound in
AddUIShared(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:42-45); read by the named"APIClient"HttpClientfactory to setBaseAddress(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:124-136, alongside the 90-second total-request timeout at:120); read byApiFileDownloadButtonfor its browser download URL (Components/Forms/ApiFileDownloadButton.razor.cs:98); read byNotificationHubServiceandBlazorCspPolicyProvider; and served to the WebAssembly client by each Server head's/client-configendpoint, both routed through the sharedMapClientConfigEndpoint(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/ClientConfig/ClientConfigEndpointExtensions.cs:52-61; called atMMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:265-281andMMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:247).
MobileInfiniteScrollList<TItem>
MMCA.Common.UI ·
MMCA.Common.UI.Components.Lists·MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/Lists/MobileInfiniteScrollList.razor.cs:20· Level 3 · class (generic partial component)
- What it is: the mobile card list every list page falls back to on a narrow viewport. It owns the whole loop: an IntersectionObserver sentinel that asks for the next page, an accumulated item list rendered through a caller-supplied card template, a rendered-item cap that bounds DOM growth, generation-guarded supersession of in-flight fetches, and localized load-failure handling with a Retry button.
- Depends on:
Resultand its generic form (the fetch delegate's return type),SharedResourcethroughIStringLocalizer<SharedResource>,ResultUiExtensions.LocalizedErrorMessage, theEmptyStatecomponent, theClickableCardcomponent each row's card is wrapped in, and the shared_content/MMCA.Common.UI/infinite-scroll.jsmodule it shares withInfiniteScrollSentinel. Externals:IJSRuntime,DotNetObjectReference,CancellationTokenSource, MudBlazor primitives. - Concept introduced, generation-guarded supersession.
[Rubric §19, State Management](assesses whether concurrent updates to client state have a defined winner) and[Rubric §23, Front-End Performance]. The hard problem in an infinite list is not fetching, it is what happens when the user changes the filter while a fetch is in flight. Cancellation alone is not enough: the fetch delegate is consumer-supplied and may ignore itsCancellationTokenentirely, so a superseded call can still complete successfully and try to append rows to a list that was cleared. The answer here is a monotonically increasing_generationcounter (line 62). A load snapshots it before awaiting and discards its results if the value moved while it waited (lines 181-183, 199). The token cancellation is still issued (it stops work that does honor it), but the generation, not the token, is authoritative (lines 196-198). The second half of the pattern is that the page counter is computed, not committed:targetPage = _currentPage + 1(line 190) and_currentPageonly advances on a successful, non-superseded completion (line 214), so a cancelled, failed or superseded fetch leaves nothing to compensate back and no page is ever re-requested (lines 187-189). - Walkthrough:
- Injected services (lines 22-23:
IJSRuntimeand the localizer, no toast service) and parameters (lines 25-52).CardTemplateis an[EditorRequired]RenderFragment<TItem>(line 27).FetchPageResult(line 37), also[EditorRequired], is the fetch delegate in the shape every Result-returning UI service already has,(page, pageSize, cancellationToken)returningResult<(IReadOnlyList<TItem> Items, int TotalItems)>, which isIEntityService<TEntityDTO, TIdentifierType>.GetPagedAsyncminus the filter and sort arguments.PageSizedefaults to 10 (line 39),MaxRenderedItemsto 500 (line 52). - State (lines 54-87):
_items,_totalCount,_currentPage,_generation, the four render flags (_isInitialLoad,_isLoadingMore,_hasMore,_loadError),_loadErrorMessage, the interop handles (_sentinelRef,_jsModule,_dotNetRef,_observerId,_cts,_observerAttached),_reobservePending(line 86) and_disposed. OnInitializedAsync(lines 89-95) validates the delegate then loads page 1.ValidateFetchParameter(lines 102-109) throws anInvalidOperationExceptionwhenFetchPageResultis null, deliberately before the load, so a misconfigured call site fails loudly instead of rendering as a load failure with a Retry button that can never succeed (lines 97-101).OnAfterRenderAsync(lines 111-120) consumes and clears_reobservePending(lines 113-114), then attaches the observer only while more pages exist, there are items to scroll past and the initial load is done, either when none is attached yet or when a re-observe was queued (line 116).AttachObserverAsync(lines 122-136) andDetachObserverAsync(lines 138-153) import and call the sameobserve/unobservemodule functions the sentinel component uses, toleratingJSDisconnectedExceptionon both paths.OnSentinelVisible()(lines 155-168), the[JSInvokable]entry point, early-returns when already loading, exhausted or disposed (lines 158-161), then loads on the renderer's context and re-renders.LoadNextPageAsync(bool isInitial)(lines 170-252) is the core. It guards re-entry (lines 172-175), clears the error state (lines 178-179), snapshots the generation and publishes a freshCancellationTokenSource(lines 183-185), computestargetPage(line 190), and awaits the delegate (line 194). After the await it checks disposal and generation (line 199), unwraps theResultwithTryGetValueand routes a failure toSetLoadFailed(lines 204-210), and only then commits: advance the page (line 214), append the items throughAppendNewand keep the count of new rows (line 216), record the total (line 217), set_hasMorefromHasMoreAfter(appended)(line 218), which is where the DOM cap and the empty-append guard stop the loop, and callMarkReobserveAfterAppend(isInitial)(line 220).OperationCanceledExceptionis swallowed as a normal supersession (lines 222-225); any other exception raises the generic failure, again only for the current generation (lines 226-234). Thefinally(lines 235-251) is careful about ownership: only the current generation may clear_isLoadingMore(a superseding reset already cleared it and may have set it again), and only the still-currentCancellationTokenSourceis disposed here (ReferenceEquals, line 244), because a resetter that took one over already cancelled and disposed it.AppendNew(IEnumerable<TItem> items)(lines 260-268) appends only the rows not already shown and returns how many were new. Offset paging plus an insert ahead of the window can repeat a row across pages, and rows are keyed by value (@key="item"), so a repeat would be two siblings with one key and a render error (lines 254-259). It filters lazily with_items.Containswhile adding (lines 264-265), so a row repeated inside the same page is caught too; records compare by value, the same equality@keyuses.HasMoreAfter(int appended)(lines 276-277) isappended > 0 && _items.Count < _totalCount && _items.Count < MaxRenderedItems: besides the rendered-item cap it stops when a page added nothing new (an empty page, or a window shifted by a whole page), so the re-observed sentinel cannot re-request the same page forever;ResetAsyncstarts over (lines 270-275).MarkReobserveAfterAppend(bool isInitial)(lines 283-289) sets_reobservePendingafter a non-initial append that left more pages. The IntersectionObserver reports threshold crossings only, so a sentinel still inside the viewport after the append would never fire again; re-observing creates a fresh observer, which delivers an initial entry with the current state (lines 83-85). This is the same stallInfiniteScrollSentinelguards against with its post-load re-attach.SetLoadFailed(Result? failure)(lines 301-305) sets the inline error state and nothing else: no toast is raised, because the inline alert is announced on its own, rendered in place of the list for a failed first page or below it for a later page (lines 291-294). The message comes fromfailure?.LocalizedErrorMessage(L)(line 304); a raw exception passesnull, because exception text is neither translatable nor safe to surface (lines 295-299), and the generic resource string is used instead.CardCallback(TItem item)(lines 314-317), declared betweenSetLoadFailedandRetryAsync, binds one row's click behavior for the markup below. WhenOnCardClickhas a delegate it returns anEventCallback.Factory.Createwrapping the invoke; when it does not, it returnsdefault, deliberately notEventCallback.Empty, becauseEventCallback.Emptywraps a no-opActionso itsHasDelegateis still true and every card would present as interactive.defaultis the only valueClickableCardreads as "no handler wired", which is what lets it render a plain, non-focusable card instead of a keyboard control that does nothing (lines 307-313).ResetAsync()(lines 329-365) is the public API a page calls when filters change. Order matters and is commented: bump the generation first so any in-flight fetch is already superseded (line 333), then cancel and dispose the stale token source (lines 335-342), then clear_isLoadingMoreexplicitly (lines 344-347, because the superseded load will not clear it), then reset the list and every flag (lines 349-355), detach the observer (lines 357-358), and reload from page 1 (lines 360-364).DisposeAsync(lines 367-399) guards re-entry, cancels and disposes the token source, detaches the observer, disposes the JS module toleratingJSDisconnectedException, and disposes theDotNetObjectReference.- The markup (
Components/Lists/MobileInfiniteScrollList.razor:1-64) renders one of four shapes: an indeterminate progress bar inside arole="status"live region on the initial load (lines 5-14), the load-failure alert when the first page itself failed (lines 15-20, because a failed first page is not an empty list),EmptyStatewhen the list came back empty (lines 21-24), or aMudStackofClickableCardwrappers around the caller's template, each keyed by item and wired toCardCallback(item)(lines 27-34). Below the cards it renders the sentineldivonly while_hasMore, with a polite live-region spinner while a page loads (lines 36-49), and the same alert when a later page failed (lines 51-54). That alert is oneRenderFragment,LoadFailedAlert, carryingrole="alert"with the message and a Retry button (lines 58-63).
- Injected services (lines 22-23:
- Why it's built this way: the component encapsulates the part of infinite scroll that is genuinely hard to get right (supersession, cancellation ownership, disposal, the DOM cap) and leaves the part that is app-specific (what a card looks like, where the data comes from) to parameters. The
Result-returning delegate rather than a rawTask<List<T>>is what makes a localized failure message reachable without the component knowing any error catalogue. - Where it's used: the mobile branch of nearly every list page. ADC:
SessionList.razor:56,SpeakerList.razor:41,SponsorList.razor:41,RoomList.razor:41,EventList.razor:31,ActivityList.razor:41,QuestionList.razor:27,ConferenceCategoryList.razor:27, the public viewsPublicSessionListView.razor:6andPublicEventList.razor:23, the check-inAttendeeSearchPanel.razor:27, andMMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/Users/UserList.razor:27. Store:MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.UI/Pages/Orders/OrderList.razor:26andPages/ShoppingCart/ShoppingCartList.razor:19. It is also exercised in the component gallery (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Gallery/Pages/ComponentsGallery.razor:57) and covered byMobileInfiniteScrollListTests. - Caveats / not-in-source:
MaxRenderedItemsbounds the DOM but there is no virtualization, so 500 rendered cards remain in the DOM; whether that is acceptable on a given device is not determinable from source. A consumer fetch delegate that ignores itsCancellationTokenstill runs to completion after a reset: the generation guard discards its results, but the request itself is not stopped.
MoneyExtensions
MMCA.Common.UI ·
MMCA.Common.UI.Extensions·MMCA.Common/Source/Presentation/MMCA.Common.UI/Extensions/MoneyExtensions.cs:14· Level 5 · class (static)
- What it is: the presentation-layer formatter for money, turning a
Moneyvalue object into$12.50 USDand a collection of them into a per-currency range such as$10.00 - $25.00 USD, formatted under a caller-chosen or ambient culture. - Depends on:
Moneyand itsCurrency(bothMMCA.Common.Shared.ValueObjects), plusCultureInfo.CurrentCultureandStringComparer.Ordinal(BCL). - Concept introduced, formatting lives in the UI layer, not the value object.
[Rubric section 3, Clean Architecture](assesses whether presentation concerns stay out of the inner layers:Moneyknows amounts and currencies, it does not know what a price looks like) and[Rubric section 20, Design System and Theming](assesses consistent presentation of a recurring data shape; one formatter means every price on every page reads the same). It also shows the C#extension(T)preview syntax used for something other than DI: two blocks, one onMoneyand one onIReadOnlyCollection<Money>, sit in a single static class so both spellings (price.ToDisplayString()andprices.ToDisplayRange()) are available from oneusing. - Walkthrough:
- The class carries a file-level
[SuppressMessage("Naming", "CA1708")](lines 10-13): with two or moreextension(T)blocks in one static class, CA1708 flags the compiler-generated grouping members as case-colliding. The justification records that no user-visible identifier differs only by case, a known analyzer trap of the preview syntax. extension(Money price)(lines 16-31) exposesToDisplayString(CultureInfo? culture = null)(lines 29-30), which delegates toFormatGroup(price.Amount, price.Amount, price.Currency.Code, culture): passing the same value as both bounds is what makes the shared helper render a single price rather than a degenerate range. Left unset,cultureresolves insideFormatGrouptoCultureInfo.CurrentCulture, the request's culture as already set by its culture provider; a caller passes one explicitly only when rendering off the reader's thread (a background job, an export, a test).extension(IReadOnlyCollection<Money> prices)(lines 33-64) exposesToDisplayRange(CultureInfo? culture = null)(lines 47-63): returnsstring.Emptyfor an empty collection (lines 49-52), resolvesculture ?? CultureInfo.CurrentCultureonce intoresolved(line 54, ahead of the grouping), then groups byCurrency.CodewithStringComparer.Ordinaland formats each group from its own min and max underresolved(lines 58-60), joining the groups with", "(line 62). Grouping is the load-bearing detail: a mixed-currency collection renders one range per currency, each with its own symbol, instead of collapsing unrelated amounts under whichever currency appeared first. The inline comment notesGroupBypreserves first-appearance order, so the single-currency case (every collection in practice today) is unchanged. Resolving the culture once outside theSelectand passing it into everyFormatGroupcall is what keeps a one-element range and the single price it holds reading identically: the two paths must not drift.Symbol(string code)(lines 71-76), a private switch mapping"USD"to$and"EUR"to the escaped euro sign (line 74, escaped to keep the source file ASCII-only). Every other code, including the empty code of theCurrency.Nonesentinel behindMoney.Zero()(MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Financial/Currency.cs:25,Money.cs:160), renders with no symbol rather than falsely claiming dollars.FormatGroup(decimal min, decimal max, string code, CultureInfo? culture)(lines 88-97), the single formatting path: resolvesculture ?? CultureInfo.CurrentCulture(line 90), then renders a single price whenmin == maxand a hyphen-separated range otherwise, each bound throughFormatAmount(lines 92-94), and appends the trailing code only when it is non-empty (line 96). Only the digit grouping and decimal separator follow the culture; the currency symbol and the trailing ISO code always come from the money itself, so a USD price stays USD in every locale.FormatAmount(decimal amount, string symbol, CultureInfo culture)(lines 103-106) formats one amount with"N2"under the resolved culture, so an English reader gets$1,234.56and a Spanish reader gets$1.234,56. A negative amount puts the sign before the symbol (-$5.00, not$-5.00) by formattingMath.Abs(amount)behind a leading-, which keeps the culture's digit grouping intact (lines 99-102).
- The class carries a file-level
- Why it's built this way: presentational formatting belongs above the domain, so
Moneystays display-agnostic and the same value can be rendered differently by a different head. The move from a hardcodedCultureInfo.InvariantCultureto a caller-optional, request-defaultedCultureInfo.CurrentCulturelets the number format follow the reader's culture (a Spanish reader's thousands and decimal separators) while the currency symbol and ISO code stay tied to the money's own currency, so the price never implies a currency the data does not carry. The optional parameter keeps every existing call site source-compatible while opening a path for a background job, an export, or a test to format under an explicit culture instead of the ambient one. - Where it's used: Store's Sales and Catalog UIs.
ToDisplayString()renders order totals and line amounts (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.UI/Pages/Orders/OrderLinesPanel.razor:63,:39,:51;Pages/Order/OrderSummaryPanel.razor:54;Pages/Order/OrderList.razor:36,:102) and the cart's order-created snackbar (Pages/ShoppingCart/ShoppingCartDetail.razor.cs:354);ToDisplayRange()renders the price span across a product's variants in catalog browse (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.UI/Pages/Catalog/CatalogBrowse.CardFormatting.razor.cs:38, with the single-price helper alongside it at:41) and on the catalog product detail page (Pages/Catalog/CatalogProductDetail.razor.cs:266,:269). None of these call sites pass acultureargument, so all of them format under the ambientCultureInfo.CurrentCulture. Covered byMoneyExtensionsTests. - Caveats / not-in-source: only
USDandEURhave symbols; adding a currency means editingSymbol, there is no configuration-driven table. The"N2"format assumes a two-minor-unit currency, so a zero-decimal currency (JPY) would render two spurious decimals; no code guards that today.
BuiltInStrings
MMCA.Common.UI ·
MMCA.Common.UI.Globalization·MMCA.Common/Source/Presentation/MMCA.Common.UI/Globalization/InvariantMudLocalizationInterceptor.cs:73· Level 0 · class (private, sealed, nested, implementsIStringLocalizer)
- What it is: a private class nested inside
InvariantMudLocalizationInterceptorthat resolves MudBlazor's own built-in English strings straight from its embedded resource file, instead of MudBlazor's generatedLanguageResourceclass, which isinternalto the MudBlazor assembly and therefore unreachable directly. - Depends on:
System.Resources.ResourceManagerandSystem.Globalization.CultureInfo(BCL); reads the manifest resource named"MudBlazor.Resources.LanguageResource"offtypeof(MudLocalizer).Assembly(MudBlazor, NuGet). Nothing first-party beyond its owner. - Concept: no new pattern; addressing an internal library resource by its manifest name, plus a minimal singleton (a
privateconstructor and astatic Instanceproperty, lines 79-83). - Walkthrough:
this[string name](85-92) callsResources.GetString(name, CultureInfo.InvariantCulture)(89) and returns aLocalizedStringwhoseresourceNotFoundflag is set when that lookup returns null.this[string name, params object[] arguments](94-104) does the same lookup, then formats the resolved string withCultureInfo.CurrentCulturewhen it was found, or falls back to the barenamewhen it was not (98-101).GetAllStrings(bool includeParentCultures)(106-122) enumerates the invariant-cultureResourceSet(108) and yields aLocalizedStringper entry, or nothing if the set cannot be created (109-112). - Why it's built this way:
privateandsealed, nested inside its only consumer, because it exists solely to giveInvariantMudLocalizationInterceptoranIStringLocalizerit can pass as the English fallback to the baseAbstractLocalizationInterceptorconstructor; reading the manifest resource by string name is the only way to reach MudBlazor's built-in strings since the generated resource class itself is internal to MudBlazor. - Where it's used: constructed once via
Instanceand passed as the built-in localizer argument toInvariantMudLocalizationInterceptor's base constructor call (line 38); not referenced outside the file that defines it.
NotificationPageNotFoundStatus
MMCA.Common.UI ·
MMCA.Common.UI.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/NotificationPageNotFoundStatus.cs:11· Level 0 · class (internal, sealed)
- What it is: a render-nothing
ComponentBasewhose only job is to tell the host the response is a 404 when the router answers a notification route with the not-found page. - Depends on: externals only:
Microsoft.AspNetCore.Components(ComponentBase,[Inject],NavigationManager). No first-party types. - Concept introduced, a not-found that is a real status.
[Rubric §25, Navigation, Routing & Information Architecture]assesses whether the routing answer matches what the host composed. Rendering the not-found page alone would still return HTTP 200 on a server render; callingNavigationManager.NotFound()is the framework signal that sets the 404 status. - Walkthrough
Navigation(lines 14-15) is an[Inject]ed privateNavigationManager.OnInitialized()(line 18) is the whole behavior:Navigation.NotFound().
- Why it's built this way: the call has to run inside a component lifecycle, so the signal is a tiny component rather than code in
Routes.razor;internalbecause only the router uses it. - Where it's used: mounted through
<DynamicComponent Type="typeof(NotificationPageNotFoundStatus)" />in the hidden branch of the router (MMCA.Common/Source/Presentation/MMCA.Common.UI/Routes.razor:25), next to the not-found page rendered by NotificationPageGate's decision.
NotificationPageRequirement
MMCA.Common.UI ·
MMCA.Common.UI.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/NotificationPageRequirement.cs:20· Level 0 · class (internal, sealed)
- What it is: the
IAuthorizationRequirementbehind themmca:notification-pagespolicy that the notification pages declare instead of a bare[Authorize]. It is a marker with one constant,PolicyName(line 23). - Depends on: externals only:
Microsoft.AspNetCore.Authorization(IAuthorizationRequirement). The class doc references LayoutSettings and NotificationPageGate. - Concept introduced, a policy that can defer to the router.
[Rubric §11, Security]and[Rubric §25, Navigation, Routing & Information Architecture]. A bare[Authorize]becomes endpoint metadata, so on a Blazor Web host the authorization middleware challenged a signed-out request (a sign-in redirect) before the router's gate ran, and a page that does not exist for this host asked the visitor to sign in (class remarks, lines 14-19). The requirement carries no data; the decision is made by NotificationPageAuthorizationHandler. - Walkthrough:
PolicyNameis"mmca:notification-pages"(line 23). The type has no other members. - Why it's built this way: a named policy with its own requirement lets the pass condition be "authenticated, or the pages are hidden" while still reading as
[Authorize]semantics on the page. - Where it's used: the policy is added in
AddUISharedwithNotificationPageRequirement.PolicyNameandnew NotificationPageRequirement()(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:81-83); evaluated by the handler (NotificationPageAuthorizationHandler.cs, 3 references).
PseudoLocalizer
MMCA.Common.UI ·
MMCA.Common.UI.Globalization·MMCA.Common/Source/Presentation/MMCA.Common.UI/Globalization/PseudoLocalizer.cs:20· Level 0 · class (static)
- What it is: a pure string transform that "pseudo-localizes" text. It accents every letter, pads the result by roughly 40% to simulate real-translation expansion, and wraps it in
[!! ... !!]bracket sentinels, while leaving composite-format placeholders ({0},{name}) byte-identical so the string can still be formatted with arguments (ADR-027 §8). - Depends on:
System.Text.StringBuilderandchar.IsLetter(BCL). Nothing first-party. It is consumed byPseudoStringLocalizer. - Concept introduced, pseudo-localization as an i18n fitness test.
[Rubric §27, Internationalization](assesses whether the app is genuinely translation-ready, not just wired for one extra language) and[Rubric §28, Front-End Testing](assesses whether i18n defects are caught automatically). Pseudo-localization is a development-time technique that surfaces three classes of bug in a single visual pass, without needing a real second translation, and the<remarks>block (PseudoLocalizer.cs:12-19) enumerates exactly those three: (1) any string that stays plain ASCII was hard-coded rather than pulled from a resource, and stands out beside the accented text; (2) any UI that truncates the padded text has a fixed-width layout that a real (longer) translation would break; (3) any label built by concatenating fragments shows one sentinel per fragment, exposing the joins that translate badly. - Walkthrough:
- Three constants (lines 22-24):
OpenSentinel = "[!! ",CloseSentinel = " !!]", andCombiningAcute(the combining acute accent code point) appended after each base glyph so the letter stays readable while visibly altered. Transform(string value)(lines 30-74): returns null/empty input unchanged (lines 32-35); pre-sizes aStringBuilderwith slack for the padding (line 37) and appends the open sentinel (line 38); then walks each character in aswitch(lines 42-66) tracking aninsidePlaceholderflag toggled by{and}(lines 46-53) so placeholder bodies are copied verbatim, and for every letter outside a placeholder appends the combining accent and increments aletterscounter (lines 54-64); finally computes the pad length asMath.Max(1, letters * 2 / 5)(about 40%, line 69), appends a separating space (line 70), that many~characters (line 71) and the close sentinel (line 72), and returns the string (line 73).
- Three constants (lines 22-24):
- Why it's built this way: keeping the transform pure and static (input string to output string, no culture check inside) makes it trivially unit-testable and lets the culture gating live one layer up in
PseudoStringLocalizer. Preserving{...}placeholders is essential: transforming them would corruptstring.Format, so pseudo-loc must accent the template and only then substitute arguments (see the two-step inPseudoStringLocalizer). - Where it's used: called by
PseudoStringLocalizeron every resolved string when the current UI culture is the pseudo locale (SupportedCultures.PseudoLocale, referenced in the doc comment at line 10); inert otherwise. Covered byPseudoLocalizationTests.
StringLocalizerPluralExtensions
MMCA.Common.UI ·
MMCA.Common.UI.Globalization·MMCA.Common/Source/Presentation/MMCA.Common.UI/Globalization/StringLocalizerPluralExtensions.cs:20· Level 0 · class (static)
- What it is: a static extension class that adds a
Plural(key, count, args)method toIStringLocalizer, resolving the count-appropriate resource key (a.Onesuffix for a count of exactly one,.Otherfor every other count, zero included) with a fallback to the base key when the plural-specific entry is missing. - Depends on:
Microsoft.Extensions.Localization.IStringLocalizer/LocalizedString(NuGet). No first-party dependency. - Concept introduced, plural-form resolution via key suffixing.
[Rubric §27, Internationalization]assesses whether the app handles languages whose plural rules diverge from a naive singular-versus-plural split; this extension implements the simpler English-style two-category split (one, other) as a suffix convention on top of the standardIStringLocalizerindexer, rather than pulling in a full CLDR plural-rules library.OneSuffix(".One", line 23) andOtherSuffix(".Other", line 26) are the two constants callers key their resx entries against. The C#extension(IStringLocalizer localizer)block (line 28) is the new extension-member syntax the workspace'sLangVersion: previewsetting enables, in place of a classicthis IStringLocalizer localizerfirst parameter. - Walkthrough:
Plural(string key, int count, params object[] args)(lines 40-52) validates its arguments (42-44), pickspluralKeyaskey + OneSuffixwhencount == 1orkey + OtherSuffixotherwise (46), resolves it through the localizer's indexer (47), and if that lookup'sResourceNotFoundis true, falls back to the basekeyitself (51): the comment at 49-50 records why the fallback matters,ResourceNotFoundis how aResourceManager-backed localizer reports a missing key, by handing back the key name itself, which must never reach the screen. - Why it's built this way: the fallback to the unsuffixed
keymeans a resx file that has not yet been split into.One/.Otherentries still renders something legible instead of a raw resource-key string; the suffix convention keeps plural-aware resources in the same flat resx format every other string uses, no separate plural-rules resource format. - Where it's used: not referenced outside the file that defines it.
InvariantMudLocalizationInterceptor
MMCA.Common.UI ·
MMCA.Common.UI.Globalization·MMCA.Common/Source/Presentation/MMCA.Common.UI/Globalization/InvariantMudLocalizationInterceptor.cs:37· Level 1 · class (internal, sealed)
- What it is: MudBlazor's interception point for resolving its own chrome text, tuned so the neutral
"en"culture (whose parent is the invariant culture) still routes through the app'sMudLocalizerlike every other non-English culture, and only"en-US"(and other cultures whose parent is literally English) short-circuits straight to MudBlazor's built-in strings (ADR-027). - Depends on:
BuiltInStrings(its own nested Level 0 type, passed as the built-in localizer), an optionalMudLocalizerconstructor parameter (MudBlazor, NuGet), and the base class the constructor chains into,AbstractLocalizationInterceptor(BuiltInStrings.Instance, mudLocalizer)(line 38). - Concept introduced, matching a library's own "is this English" test rather than assuming one.
[Rubric §27, Internationalization]. The comment at lines 46-48 records the design constraint directly: this interceptor deliberately reproduces MudBlazor's own definition of "is this culture English", the culture's parent's two-letter ISO name equals"en"so a specific culture like"en-US"counts as English, but the neutral"en"culture itself (whose parent is the invariant culture, not English) does not, and instead flows throughMudLocalizerlike any other culture, falling back to the built-in string only if that lookup reportsResourceNotFound. - Walkthrough: the primary constructor (line 37) takes an optional
MudLocalizer? mudLocalizerand passes it, alongsideBuiltInStrings.Instance, to the baseAbstractLocalizationInterceptor.Handle(string key, params object[] arguments)(lines 41-61) validates its arguments (43-44), computesisEnglishby comparingCultureInfo.CurrentUICulture.Parent.TwoLetterISOLanguageNameto"en"withOrdinalIgnoreCase(49-52), and returns straight fromBuiltIn(key, arguments)(56) whenMudLocalizeris null or the culture is English (54-57); otherwise it triesMudLocalizer[key, arguments](59) and falls back toBuiltInwhen that translation reportsResourceNotFound(60). The privateBuiltIn(string key, object[] arguments)helper (65-66) is a no-argument-indexer call whenargumentsis empty, the comment at 63-64 notes that formatting a value containing literal braces with an empty argument list would throw, and MudBlazor's own strings are looked up the same way. - Why it's built this way: reusing MudBlazor's own English-detection rule, rather than a simpler "does the culture name start with
en" check, keeps this interceptor's fallback behavior identical to MudBlazor's defaultDefaultLocalizationInterceptorfor the one case (the neutral"en"culture) where a naive check would diverge from it. - Where it's used:
MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs(1 reference, its registration). Tested byMMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Globalization/InvariantMudLocalizationInterceptorTests.cs.
PseudoStringLocalizer
MMCA.Common.UI ·
MMCA.Common.UI.Globalization·MMCA.Common/Source/Presentation/MMCA.Common.UI/Globalization/PseudoStringLocalizer.cs:13· Level 1 · class (sealed)
- What it is: an
IStringLocalizerdecorator that pseudo-localizes every resolved string, but only when the current UI culture is the pseudo locale; under every other culture it delegates unchanged to the wrapped localizer, so it is inert in production (ADR-027 §8). - Depends on:
PseudoLocalizer(the transform, Level 0),SupportedCultures(itsIsPseudoLocalefromMMCA.Common.Shared.Globalization), andIStringLocalizer/LocalizedString/CultureInfo(BCL and NuGet). Constructed with aninnerIStringLocalizervia a primary constructor (line 13). - Concept introduced, the decorator that gates on culture.
[Rubric §2, Design Patterns](assesses idiomatic use of patterns; this is a textbook Decorator, same interface in and out, wrapping behavior around a delegate) and[Rubric §27, Internationalization]. The key design move is that pseudo-localization is a cross-cutting transform applied to the localizer, not to any call site: because it implementsIStringLocalizerand forwards toinner, it can be slid underneath everyIStringLocalizer<T>in the app at once by decorating the factory (PseudoStringLocalizerFactory), with zero changes to consumers. - Walkthrough:
IsPseudoActive(lines 16-17), a private static bool that returnsSupportedCultures.IsPseudoLocale(CultureInfo.CurrentUICulture.Name), the single gate every member checks.this[string name](lines 20-29): resolvesinner[name](line 24), then, if pseudo is active, returns a newLocalizedStringwhose value isPseudoLocalizer.Transform(localized.Value)while preservingResourceNotFound/SearchedLocation(line 26); otherwise returns the inner value untouched (line 27).this[string name, params object[] arguments](lines 32-48): when pseudo is inactive, delegates straight toinner[name, arguments](lines 36-39); when active it does the two-step that makes placeholders survive, transform the raw template first (lines 43-44), thenstring.Formatthe accented template with the arguments (line 45), so the substituted values are never accented or padded.GetAllStrings(bool includeParentCultures)(lines 51-57): maps the transform over every string when active (line 55), passes them through otherwise (line 56).
- Why it's built this way: gating inside the decorator (rather than conditionally registering it) keeps DI wiring unconditional and simple, the decorator is always present and simply does nothing outside the pseudo locale, which per the doc comment (lines 10-11) is never an activatable request culture in production. Splitting the pure transform (
PseudoLocalizer) from the culture-aware decorator keeps each single-responsibility and independently testable ([Rubric §1, SOLID]). - Where it's used: produced by
PseudoStringLocalizerFactoryaround every localizer the inner factory creates, so it transparently wrapsIStringLocalizer<SharedResource>,IStringLocalizer<MudTranslations>, and every other localizer in the host.
ResxMudLocalizer
MMCA.Common.UI ·
MMCA.Common.UI.Globalization·MMCA.Common/Source/Presentation/MMCA.Common.UI/Globalization/ResxMudLocalizer.cs:17· Level 1 · class (sealed, internal)
- What it is: MudBlazor's
MudLocalizerimplementation that resolves the library's built-in component text from theMudTranslationsresource pair, so MudBlazor chrome (pager, filter menus, pickers, close buttons) follows the active UI culture (ADR-027). - Depends on:
MudBlazor.MudLocalizer(the abstract base, NuGet),IStringLocalizer<MudTranslations>(injected via primary constructor, line 17), andMudTranslations(Level 0). Nothing else first-party. - Concept introduced, adapting a third-party localization hook.
[Rubric §2, Design Patterns](this is an Adapter, bridging MudBlazor'sMudLocalizercontract to the ASP.NET CoreIStringLocalizerworld) and[Rubric §27, Internationalization]. MudBlazor exposes exactly one extension point for translating its built-in strings: subclassMudLocalizerand override its indexer. This adapter routes that indexer straight toIStringLocalizer<MudTranslations>. MudBlazor's ownDefaultLocalizationInterceptorconsults this localizer only for non-English cultures and falls back to its built-in English whenever the returnedLocalizedString.ResourceNotFoundis true (per the doc comment,ResxMudLocalizer.cs:9-12), so any untranslated key degrades gracefully. - Walkthrough: a one-member class.
internal sealed class ResxMudLocalizer(IStringLocalizer<MudTranslations> localizer) : MudLocalizer(line 17) with a singlepublic override LocalizedString this[string key] => localizer[key];(line 19). The doc comment (lines 13-15) also notes that because resolution flows through the DIIStringLocalizerFactory, thePseudoStringLocalizerFactorydecorator applies here too, so under the development-onlyqps-Plocculture MudBlazor's chrome pseudo-localizes alongside the application text. - Why it's built this way:
internalbecause it is pure host wiring no consumer needs to name; delegating to the injectedIStringLocalizer<MudTranslations>reuses the exact same.resx/factory pipeline as app strings (one localization mechanism, not two), which is what lets pseudo-loc reach MudBlazor for free. - Where it's used: registered as MudBlazor's
MudLocalizerinAddUISharedviaservices.TryAddTransient<MudBlazor.MudLocalizer, ResxMudLocalizer>()(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:101).TryAddis authoritative becauseAddMudServicesdoes not register aMudLocalizerof its own (guarded by a DI-resolution test, per the comment atDependencyInjection.cs:97-100), regardless of host registration order. Covered byResxMudLocalizerTests.
PseudoStringLocalizerFactory
MMCA.Common.UI ·
MMCA.Common.UI.Globalization·MMCA.Common/Source/Presentation/MMCA.Common.UI/Globalization/PseudoStringLocalizerFactory.cs:11· Level 2 · class (sealed)
- What it is: an
IStringLocalizerFactorydecorator that wraps every localizer the inner factory produces in aPseudoStringLocalizer, so decorating this one factory pseudo-localizes everyIStringLocalizer<T>andIStringLocalizerin the host at once (ADR-027 §8). - Depends on:
PseudoStringLocalizer(Level 1) andIStringLocalizerFactory/IStringLocalizer(Microsoft.Extensions.Localization, NuGet). Constructed with theinnerfactory via a primary constructor (line 11). - Concept introduced, decorate the factory to reach every product.
[Rubric §2, Design Patterns](Decorator applied at the factory level) and[Rubric §6, CQRS & Event-Driven Design](assesses whether cross-cutting behavior is injected in one place rather than scattered). BecauseStringLocalizer<T>resolves its backing localizer through theIStringLocalizerFactory, wrapping the factory means every localizer the DI container ever hands out is already pseudo-aware: no per-type registration, no consumer change. This is the same "decorate the boundary, not the callers" idea the CQRS pipeline uses (see primer §2), applied to localization. - Walkthrough: two forwarding overrides, each wrapping the inner factory's product:
Create(Type resourceSource)(lines 14-15):new PseudoStringLocalizer(inner.Create(resourceSource)), the path used byIStringLocalizer<T>.Create(string baseName, string location)(lines 18-19):new PseudoStringLocalizer(inner.Create(baseName, location)), the path used by name-based localizers.
- Why it's built this way: registering the wrapper on the factory is the minimal, DI-idiomatic way to make pseudo-loc universal; combined with the culture gate inside
PseudoStringLocalizer, it can be registered unconditionally because it is inert under every non-pseudo culture, so production wiring is not conditional on environment (the registration comment,DependencyInjection.cs:90-94, says exactly that: the pseudo locale is only ever activatable in Development). - Where it's used: registered via
services.Decorate<IStringLocalizerFactory, PseudoStringLocalizerFactory>()(Scrutor) inAddUIShared(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:95), afterservices.AddLocalization()(line 60). Its reach includes MudBlazor chrome throughResxMudLocalizer, which resolves itsIStringLocalizer<MudTranslations>through this same factory.
NotificationUIModule
MMCA.Common.UI ·
MMCA.Common.UI.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/NotificationUIModule.cs:15· Level 7 · class (sealed)
- What it is: the notification feature's IUIModule descriptor. It declares the two nav entries (user inbox, admin push notifications), the app-bar component, the layout component, and the assembly to scan for routable pages.
- Depends on: first-party: IUIModule (the contract, line 15), NavItem and NavSection (the nav item shape and its placement enum), NotificationRoutePaths (the two routes), SharedResource (the resx type each nav label resolves against), NotificationPermissions (the
Managegate), plus the NotificationBell andNotificationListenercomponents in the same package (lines 23, 25). Externals:MudBlazor(Icons.Material.Filled.*),System.Reflection(Assembly). - Concept introduced, the UI module pattern (the client-side counterpart to IModule).
[Rubric §25, Navigation, Routing & Information Architecture]assesses how navigation is composed and how routes are discovered. Server modules declare their registrations and dependencies throughIModule; UI features do the same for the shell. Nothing here calls into a layout: the module declares nav items and component types as data, and the host discovers every registered IUIModule and assembles the menu, app bar, and layout from those declarations. Adding a feature therefore never edits a sharedMainLayout.razoror a central menu file.[Rubric §18, UI Architecture & Component Design]applies to the two component collections:AppBarComponentTypesandLayoutComponentTypesareTypehandles, so the shell renders them dynamically without a compile-time reference to the feature.[Rubric §11, Security]applies to the permission gate: the admin entry carriesRequiredPermission: NotificationPermissions.Manage(line 20) on the nav item itself, so the authorization fact lives next to the thing it protects rather than in a layoutif.[Rubric §27, Internationalization & Localization]: the nav labels are resource keys plus a resource type,"Nav.NotificationInbox"and"Nav.PushNotifications"withtypeof(SharedResource)(lines 19-20), not literal English. A descriptor is a singleton built once at startup, so it cannot hold a localized string; carrying the key and the resx anchor instead is what lets the shell resolve the label per circuit under the active culture.
- Walkthrough
NavItems(lines 17-21) is an immutableIReadOnlyList<NavItem>with two entries: the inbox keyNav.NotificationInboxtoNotificationRoutePaths.NotificationInboxwith theInboxicon inNavSection.User(line 19, no role, so any authenticated user sees it), andNav.PushNotificationstoNotificationRoutePaths.Notificationswith theNotificationsActiveicon, gated onNotificationPermissions.Manage, inNavSection.Adminand grouped under the resource key"Nav.Group.Notifications"(line 20), so the group heading is localized like the labels.AppBarComponentTypes(line 23) is[typeof(NotificationBell)], the badge the shell injects into the top bar.LayoutComponentTypes(line 25) is[typeof(NotificationListener)], mounted once per layout so the SignalR callback wiring has exactly one owner.Assembly(line 27) returnstypeof(NotificationUIModule).Assembly, which the host adds to the Blazor router's additional assemblies so the pages in this package become routable in the consumer app.
- Why it's built this way: expressing contributions as data (collections of records and
Types) keeps the shell open for extension and closed for modification, and it is what allows a package to ship a complete feature (routes, nav, app-bar widget, background listener) that a host enables with one DI call. The class issealedand every member is a get-only auto-property initialized inline, so the descriptor is safely shared as a singleton. - Where it's used: registered as a singleton IUIModule by
AddNotificationUI()in the notifications DependencyInjection (Notifications/DependencyInjection.cs:39); enumerated by the host shell at startup to build navigation and to discover this package's routable components.
NotificationPageGate
MMCA.Common.UI ·
MMCA.Common.UI.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/NotificationPageGate.cs:12· Level 8 · class (internal, static)
- What it is: the two-method predicate (
HidesandHidesNotificationPages) the framework router consults to decide whether a matched notification page should be answered with the not-found page instead, because the host opted in and never registered the notification UI (class doc, lines 7-11). - Depends on: first-party: LayoutSettings (the
HideNotificationPagesWhenUnregisteredopt-in,MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Settings/LayoutSettings.cs:49), IUIModule (the registered modules it scans), NotificationUIModule (the module whose presence means "registered"), and the three routable pages it guards, NotificationList, NotificationInbox and NotificationSend. Externals: BCLSystem.Typeand LINQ (Contains,Any). - Concept introduced, a feature's routes follow its registration.
[Rubric §25, Navigation, Routing & Information Architecture]assesses whether what is reachable by URL matches what the host actually composed. The notification pages ship in theMMCA.Common.UIassembly itself, so they are routable in every host whether or not it calledAddNotificationUI(); a host that never registered the feature would serve pages whose services it never registered, which fail at render. The UI module pattern (ADR-067) already makes the NotificationUIModule singleton the evidence of registration, so the gate reads that rather than probing for individual services.- Opt-in, not a behavior change.
HideNotificationPagesWhenUnregistereddefaults tofalse, which leaves the routes exactly as they are, and a host that registers the notification UI is unaffected either way (LayoutSettings.cs:38-49). Only a host that sets the flag and lacks the module gets the not-found answer.
- Opt-in, not a behavior change.
- Walkthrough
NotificationPages(line 15) is a private staticType[]oftypeof(NotificationList),typeof(NotificationInbox)andtypeof(NotificationSend), the routable pages that need the servicesAddNotificationUI()registers.Hides(Type pageType, LayoutSettings settings, IEnumerable<IUIModule> modules)(lines 22-23) isNotificationPages.Contains(pageType) && HidesNotificationPages(settings, modules): the matched page must be one of the three before the host-level question is asked.HidesNotificationPages(LayoutSettings settings, IEnumerable<IUIModule> modules)(lines 29-31) is the page-independent half: the flag is on (line 30) and no registered moduleis NotificationUIModule(line 31). It ispublicso NotificationPageAuthorizationHandler can ask the same question without a page type.
- Why it's built this way: keeping the decision in a small
internal staticpredicate, with the page type, settings and module list passed in, keepsRoutes.razorto a one-line branch and lets a unit test cover every combination without rendering a router. Answering with the framework's own not-found page inside the main layout gives the user the same experience as any unknown URL instead of a render failure. - Where it's used: called once per route match from the
<Found>branch of the framework router,NotificationPageGate.Hides(routeData.PageType, LayoutOptions.Value, UIModules)(MMCA.Common/Source/Presentation/MMCA.Common.UI/Routes.razor:16), which rendersPages.NotFoundinsideMainLayoutwhen it returnstrue; pinned byNotificationPageGateTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Notifications/NotificationPageGateTests.cs, 4 references). - Caveats / not-in-source: the
LayoutSettingsdoc lists four routes (/notifications,/notifications/inbox,/notifications/inbox/{Id},/notifications/send) while the gate matches three page types, so the inbox detail route is presumably served by one of those three pages; which page carries it is not visible from these files.
NotificationPageAuthorizationHandler
MMCA.Common.UI ·
MMCA.Common.UI.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/NotificationPageAuthorizationHandler.cs:14· Level 9 · class (internal, sealed)
- What it is: the
AuthorizationHandler<NotificationPageRequirement>that evaluates themmca:notification-pagespolicy: it succeeds for any authenticated caller, and also for every caller when the host hides the notification pages. - Depends on: first-party: NotificationPageRequirement (the requirement it handles), NotificationPageGate (
HidesNotificationPages), LayoutSettings (viaIOptions<LayoutSettings>), IUIModule (the registered modules). Externals:Microsoft.AspNetCore.Authorization,Microsoft.Extensions.Options. - Concept introduced, authorization that steps aside for a 404.
[Rubric §11, Security]assesses that access rules are explicit and centrally evaluated. The handler keeps[Authorize]semantics but lets a hidden page reach the router, so a signed-out visitor to a route the host does not serve gets a not-found answer instead of a sign-in challenge (requirement doc,NotificationPageRequirement.cs:6-13). Nothing is ever failed explicitly: the handler only callsSucceed, so an unauthenticated caller on a host that shows the pages falls through to the default denial. - Walkthrough
- Primary constructor (lines 14-16) takes
IOptions<LayoutSettings> layoutOptionsandIEnumerable<IUIModule> modules. HandleRequirementAsync(lines 20-33) guardscontextwithArgumentNullException.ThrowIfNull(line 24), then callscontext.Succeed(requirement)(line 29) when any identityIsAuthenticated(line 26) orNotificationPageGate.HidesNotificationPages(layoutOptions.Value, modules)is true (line 27), and returnsTask.CompletedTask(line 32).
- Primary constructor (lines 14-16) takes
- Why it's built this way: delegating the "hidden" question to
NotificationPageGate.HidesNotificationPagesmeans the router and the policy cannot disagree about when the pages are hidden. The handler is synchronous work wrapped in a completed task because no I/O is needed. - Where it's used: registered as a transient
IAuthorizationHandlerwithTryAddEnumerableinAddUIShared(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:84-85), alongside the policy registration at lines 81-83.
DependencyInjection
MMCA.Common.UI ·
MMCA.Common.UI.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/DependencyInjection.cs:12· Level 8 · class (static)
- What it is: the notification-UI registration entry point, a single
AddNotificationUI()extension onIServiceCollectionthat wires the two typed HTTP services, the shared per-circuit state, the SignalR client, the scope provider, and the IUIModule descriptor. - Depends on: first-party: INotificationScopeProvider + NullNotificationScopeProvider, IPushNotificationUIService + PushNotificationService, INotificationInboxUIService + NotificationInboxService, NotificationState, NotificationHubService, and IUIModule + NotificationUIModule. Externals:
Microsoft.Extensions.DependencyInjection(IServiceCollection,AddScoped,AddSingleton) andMicrosoft.Extensions.DependencyInjection.Extensions(TryAddScoped). - Concept: the C#
extension(IServiceCollection services)registration idiom used package-wide (see primer); the block opens at line 14 and the method inside it is an ordinary extension method in the new form. Note this is one of severalDependencyInjectionclasses in the UI packages: this one is specifically the Notifications registrar, the sibling of theMMCA.Common.UI.Webhost registrar above.[Rubric §33, Developer Experience & Inner Loop]assesses how much a consumer must know to switch a feature on; the answer here is one call.[Rubric §3, Clean Architecture]assesses where composition lives; the feature owns its own DI, so nothing about notifications leaks into a host'sProgram.csbeyond the single line.TryAddversusAddis a deliberate signal. The scope provider is registered withTryAddScoped(line 24) precisely so an app that registers its own INotificationScopeProvider wins regardless of the order the two registration calls run in (the comment on lines 22-23 says so); everything else uses plainAddScoped/AddSingletonbecause this package owns those contracts.
- Walkthrough: inside the extension block (line 14),
AddNotificationUI()(line 20) registers, in order, INotificationScopeProvider to NullNotificationScopeProvider viaTryAddScoped(line 24, the default no-op scope consumed by both HTTP services and read for the caption on NotificationSend), IPushNotificationUIService to PushNotificationService (scoped, line 27), INotificationInboxUIService to NotificationInboxService (scoped, line 30), NotificationState as a concrete scoped type (line 33, one unread-count owner per Blazor circuit), NotificationHubService (scoped SignalR client, line 36), and finally NotificationUIModule as a singleton IUIModule (line 39), because the descriptor is immutable shell metadata rather than per-circuit state. It returnsservicesfor chaining (line 41). - Why it's built this way: the scoped-versus-singleton split is the load-bearing part. HTTP services, state, and the hub connection are per-circuit (a Blazor circuit is a DI scope, and the unread count belongs to one user's session), while the nav and shell descriptor is process-wide and immutable. Bundling all six behind one extension keeps host startup honest and makes the feature's dependency surface reviewable in one screen.
- Where it's used: called from the
Program.csof each consuming host (Blazor Web and MAUI) that opts into the notification UI; it complements the mainMMCA.Common.UIregistration rather than replacing it. - Caveats / not-in-source: this method does not register NotificationBellOptions. The bell injects
IOptions<NotificationBellOptions>(NotificationBell.razor.cs:36) and the options type supplies its own defaults (NotificationBellOptions.cs:23,30), so the unconfigured case works, but where a host binds theNotificationBellconfiguration section is not visible from this file.
PermissionGroup
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Administration·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Administration/RoleAdminEdit.razor.cs:328· Level 0 · record (private, sealed, nested)
- What it is: a private record nested inside
RoleAdminEditthat groups the permissions filed under onearea:capabilityprefix, the shape the edit form renders one checkbox section per. - Depends on: nothing first-party beyond its own owner; an
IReadOnlyList<string>for the member permissions. - Concept: no new pattern; a compact in-memory grouping record with two positional properties.
- Walkthrough:
Area(the text before the first colon, or the localized "General") andPermissions(that area's permissions, ordered). Built byRoleAdminEdit.Group(IEnumerable<string>)(RoleAdminEdit.razor.cs:308-317), which groups the catalog's permissions byRoleAdminEdit.AreaOfand orders both the groups and each group's members withStringComparer.Ordinal, so the checkbox layout is stable across loads. - Why it's built this way:
privateandsealedbecause it exists only to shape one component's markup loop; nothing outsideRoleAdminEditneeds it. - Where it's used: the
_groupsfield ofRoleAdminEdit(RoleAdminEdit.razor.cs:131), populated byGroup(...)and iterated by the permission checkbox markup.
RoleAdminEditResources
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Administration·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Administration/RoleAdminEditResources.cs:14· Level 0 · class (sealed, empty)
- What it is: an empty marker class,
public sealed class RoleAdminEditResources;, whose only job is to be the generic argument of anIStringLocalizer<RoleAdminEditResources>. - Depends on: nothing; it carries no members.
- Concept introduced, a fallback-resource marker for an app-first localization pattern.
[Rubric §27, Internationalization]assesses whether an app can supply its own wording without forking framework code.RoleAdminEditinjectsIStringLocalizer<RoleAdminEditResources>asL(RoleAdminEdit.razor.cs:160) and its privateT(key, args)helper (:205-217) checks the caller-suppliedLocalizerparameter first, falling through toL(this marker's resx) only when the app's localizer does not carry the key. The marker class itself defines no strings; it exists purely so .NET's resource-file convention (RoleAdminEditResources.resxbeside the class) has something to key against. - Walkthrough: no members.
- Why it's built this way:
sealedbecause nothing subclasses a resource marker; kept as a distinct class per component (paired withRoleAdminListResources,UserAdminListResources) rather than one shared marker, so each component's resx file, and its "app localizer not consulted for this key" fallback, is scoped independently. - Where it's used:
RoleAdminEdit.razor.cs(3 references: the injectedLproperty, theTfallback call, and the DI registration type argument) and 2 more, includingMMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs.
RoleAdminListResources
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Administration·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Administration/RoleAdminListResources.cs:14· Level 0 · class (sealed, empty)
- What it is: the same empty resx-marker shape as
RoleAdminEditResources, scoped toRoleAdminListinstead. - Depends on: nothing; no members.
- Concept reinforced: the app-localizer-first fallback
RoleAdminEditResourcesintroduces.[Rubric §27, Internationalization]. - Walkthrough: no members.
- Why it's built this way: kept distinct from
RoleAdminEditResourcesso the two components' resx files, and their independent "app localizer missing this key" fallbacks, do not share one namespace. - Where it's used:
RoleAdminList.razor.cs(3 references) and 2 more, includingMMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs.
UserAdminListResources
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Administration·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Administration/UserAdminListResources.cs:14· Level 0 · class (sealed, empty)
- What it is: the same empty resx-marker shape, scoped to
UserAdminList<TUser>. - Depends on: nothing; no members.
- Concept reinforced: the app-localizer-first fallback
RoleAdminEditResourcesintroduces.[Rubric §27, Internationalization]. - Walkthrough: no members.
- Why it's built this way: same rationale as its two siblings, one marker per component so each keeps its own resx and fallback scope.
- Where it's used:
UserAdminList.razor.cs(3 references) and 2 more, includingMMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs.
RoleAdminEdit
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Administration·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Administration/RoleAdminEdit.razor.cs:36· Level 4 · class (partial,ComponentBase, IDisposable)
- What it is: the UI half of ADR-116 role administration: an editable, grouped checkbox matrix of every permission the catalog knows about for one
Role, with code-compiled permissions shown locked and only the operator's extra grants persisted. - Depends on:
IRoleAdminUIService(injected asRoles, line 80),IToastService(Toast, 82),IStringLocalizer<RoleAdminEditResources>(L, 84, seeRoleAdminEditResources),LatestLoadGuard(MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/LatestLoadGuard.cs:38, held as_load, line 42), its own nestedPermissionGrouprecord, andAdministrationPermissions.ManageRoles(MMCA.Common.Shared.Auth.Administration). Externals:Microsoft.Extensions.Localization.IStringLocalizer, Blazor'sComponentBase. - Concept introduced, a compiled/stored permission split rendered as locked vs. editable checkboxes.
[Rubric §11, Security]assesses whether a role's code-granted authority stays visible and non-revocable from the UI;[Rubric §18, UI Architecture & Component Design]assesses whether that split is legible to the operator._compiled(line 47) is the set of permissions the role's code already grants (the registry, unconditionally locked);_stored(48) is the operator-editable overlay persisted to thePermissionGranttable. A permission is ticked when either set contains it (IsChecked, line 253) but is read-only, and therefore excluded from a save, when it is compiled OR isAdministrationPermissions.ManageRolesitself (IsReadOnly, 258-260): the second clause is a deliberate lockout guard, an operator can never uncheck the one permission that lets a role reach this very page. - Concept introduced (2), an app-localizer-first fallback with per-call override args.
[Rubric §27, Internationalization]. The privateT(key, args)helper (141-153) checks the caller-suppliedLocalizerparameter first via its indexer (overridden.ResourceNotFound), falling back toL(anIStringLocalizer<RoleAdminEditResources>) only when the app's localizer does not carry the key; the same shape repeats verbatim inRoleAdminListandUserAdminList<TUser>. - Walkthrough:
- State (39-59):
GeneralAreaKeyconst"Group.General"(39, the resource key for an ungrouped permission), a component-scopedCancellationTokenSource(41), theLatestLoadGuard_load(42, supersedes an in-flight load),_compiled/_storedasHashSet<string>(StringComparer.Ordinal)(47-48,IDE0028suppressed because a collection expression cannot carry the comparer),_groups(51, the display-readyPermissionGrouplist),_loadResult/_saveResult(52-53, the last load/save outcome for inline rendering),_loadedRole(54, guards a redundant reload),_isDirty(58, whether_storedhas diverged from what was last loaded or saved, the whole unsaved state a navigation guard protects),_disposed(59). - Parameters:
Role(62-64,[EditorRequired]),ListHref(67-69,[EditorRequired], the route back to the roster),Heading(72, optional override of the default "Permissions for {role}" title),Localizer(78). OnParametersSetAsync(108): reloads only whenRoleactually changed (string.Equals(_loadedRole, Role, Ordinal)), so a parent re-render does not throw away an operator's half-made edits. ARolechange also supersedes the load still in flight (the doc comment at 103-105), so a late answer for the previous role can never become what Save posts for the current one.Dispose(bool)(116-): cancels and disposes_ctsand also disposes_load(127).LoadAsync(160-217): opens a generation with_load.Begin()(162), capturing(token, generation), and snapshotsRoleintorequestedRole(163). It loads the role with that token, then the catalog; after eachawaitit returns silently unless_load.IsCurrent(generation)still holds (174, 187), so a superseded load never touches the form. Either failure sets_loadResultand callsReset()(219) and returns early. It clears_isDirty(169) up front, since a fresh load has nothing unsaved yet. On success it repopulates_compiledfromrole.Value.RegisteredPermissionsand_storedfromrole.Value.StoredPermissions, then builds_groupsfromcatalog.Value.PermissionsviaGroup(...). AnOperationCanceledExceptionis swallowed only when the load is no longer current (206), andfinallyclearsIsLoadingonly for the current generation (212), so the newer load owns the spinner.Group(permissions)(233): groups byAreaOf(243, the text before the first:or the localizedGeneralAreaKey), orders groups and, within each, members, both withStringComparer.Ordinal, so the rendered layout is deterministic across loads.ReadOnlyHint(permission)(265): the reason text beside a locked checkbox,Hint.Compiledwhen the code grants it,Hint.ManageRolesfor the self-lockout case, empty otherwise.Toggle(permission, granted)(277-289): adds or removes the permission from_storedonly, a compiled permission is never touched because it is not a member of_storedto begin with, and sets_isDirty = true(288) unconditionally, since either direction of a toggle is an edit worth protecting.SaveAsync(291-323): posts_stored, ordered, viaRoles.SetStoredPermissionsAsync(Role, permissions, _cts.LifetimeToken())(300). On success it does not trust what it submitted: it re-seeds_compiledand_storedfrom the server's response (the comment at 308-310 records that the two lists the server reports are disjoint, so a permission the host compiles in moves to the locked half even if the operator's own submission still carried it as stored), and it clears_isDirty(315) now that the form matches what the server holds.- The nested
PermissionGrouprecord (328) closes the file.
- State (39-59):
- Why it's built this way: reseeding from the server's answer rather than the submitted payload is the same "server is the authority, reload rather than assume" principle
Sessionsapplies after a revoke; lockingManageRolesunconditionally is a UI-level backstop for a lockout the server-side authorization gate would otherwise only catch after the fact. - Where it's used: wrapped by MMCA.ADC's
RoleEdit.razor.cs(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/Roles/RoleEdit.razor.cs), registered inMMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs. Tested byMMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Administration/RoleAdminEditTests.cs(5 tests),MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.UI.Tests/Pages/Roles/RoleEditTests.cs, and the e2e page objectMMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/PageObjects/Identity/RoleAdminPage.cs. See ADR-116.
RoleAdminList
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Administration·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Administration/RoleAdminList.razor.cs:28· Level 4 · class (partial,ComponentBase, IDisposable)
- What it is: the ADR-116 role roster: every declared role with a link into
RoleAdminEditfor each, loaded whole in one call rather than through a paged grid. - Depends on:
IRoleAdminUIService(Roles, 49),IStringLocalizer<RoleAdminListResources>(L, 51, seeRoleAdminListResources). Externals: sameComponentBase/IStringLocalizershape as its sibling. - Concept reinforced: the app-localizer-first
T(key, args)fallbackRoleAdminEditintroduces (100-112 here, identical shape).[Rubric §27, Internationalization]. - Walkthrough:
- State (30-33):
_cts,_rolesasIReadOnlyList<RolePermissionsResponse>(33, defaulting empty),_disposed. - Parameters:
Heading(36, optional),EditHref(39-41,[EditorRequired]Func<string, string>that builds the route to one role's editor),Localizer(47). OnInitializedAsync(69) callsLoadRolesAsynconce.LoadRolesAsync(114): callsRoles.GetAllAsync(_cts.LifetimeToken())(121), stores the wholeResultin_loadResult(59, private field) for inline failure rendering, and sets_rolesto the result's value on success or empty on failure.- Dispose pattern (
Dispose(bool), 76): the same idempotent cancel-and-dispose shape asRoleAdminEditandSessions.
- State (30-33):
- Why it's built this way: unlike
UserAdminList<TUser>, which pages throughMudDataGrid's server-data contract, this component loads the entire roster in oneGetAllAsynccall: the declared-role set is small and bounded by the app's own role list, so a paging protocol would add ceremony without solving a real scale problem. - Where it's used: wrapped by MMCA.ADC's
RoleList.razor.cs(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/Roles/RoleList.razor.cs), registered inMMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs. Tested byMMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Administration/RoleAdminListTests.cs(4 tests),MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.UI.Tests/Pages/Roles/RoleListTests.cs, and the e2e page objectMMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/PageObjects/Identity/RoleAdminPage.cs. See ADR-116.
UserAdminList<TUser>
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Administration·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Administration/UserAdminList.razor.cs:47· Level 5 · class (partial, generic, constrainedwhere TUser : IUserAdminDTO)
- What it is: a drop-in, generic account roster component covering the ADR-116 user-administration operator actions (lock, unlock, change role, optional delete), built as a component rather than a page so the app supplies the route, the authorization attribute, and the detail link.
- Depends on:
IUserAdminActionsUIService(Actions, 131, the three account actions, always used regardless of data source),IAppDialogService(Dialogs, 133, confirmation dialogs),AuthenticationStateProvider(135, to find the signed-in operator's own id),IServiceProvider(137, to resolveIUserAdminUIService<TUser>on demand),IStringLocalizer<UserAdminListResources>(L, 139, seeUserAdminListResources),MudDataGrid<TUser>/MobileInfiniteScrollList<TUser>(143-144),DeleteConfirmation(145),ListPageActions.ReloadActiveLayoutAsync(215), andIUserAdminDTOas theTUserbound. Its class declaration (UserAdminList.razor.cs:47-49) shows no explicit base in this partial; the overridden members (Title,GridRef,OnInitializedAsync,SaveFilters,RestoreFilters, allprotected override) matchDataGridListPageBase<TDto>'s shape, so the base is declared on the paired.razorfile, not visible in this.cshalf. - Concept introduced, two swappable data paths behind one component.
[Rubric §18, UI Architecture & Component Design]assesses whether a reusable component adapts to an app's own backend without a fork. By defaultLoadServerData(227-243) andFetchMobilePage(245) read throughUsers, a property that resolvesIUserAdminUIService<TUser>fromServiceProvideron demand (158-162) and throws a directiveInvalidOperationExceptionnaming the missing registration call if it is absent; when the app suppliesFetchPage(96-, a delegate taking the grid's filter bag, paging, sort column/direction and a token) that delegate is used instead and the reading service is never resolved. The three account actions (lock/unlock/role-change) always go throughIUserAdminActionsUIServiceregardless of which reading path is active, because both the framework's own endpoint and an app's custom listing endpoint sit in front of the same administration API. - Concept introduced (2), hiding an operator's destructive power over their own row.
[Rubric §11, Security].CanAdminister(user)(204) is false when_currentUserId(set fromAuthenticationStateProviderinOnInitializedAsync, 165) equals the row'sUserId, and every administration affordance (lock, role, delete) checks it: an operator who locked, demoted or deleted themselves would lose the very capability needed to undo it (self-service deletion stays on the profile page), and the API itself has no notion of "the caller", so the guard is UI-only and deliberately placed here. - Walkthrough:
SearchFilterKeyconst"Search"(55, the filter-bag key the search box is injected under whenFetchPageowns the fetch).- Parameters:
Heading(58),DetailHref(61-63,[EditorRequired]),AssignableRoles(70, ordered least to most privileged, offered as "Set role to X" menu items, empty offers only lock/unlock),RoleLabel(78, maps a stored role value to a display label; raw value shown when null),Columns/TrailingColumns/CardContent(81-87, extension render fragments),FetchPage(96-, the custom-endpoint override described above),Sortable(112, defaults false because the framework's own administration endpoint ignores sort and a sortable header would lie),ShowSearch(115, default true),OnDelete(122, null means no Delete affordance at all: account erasure has its own authorization rule the framework does not assume exists),Localizer(129). - State:
_currentUserId(141),_dataGrid/_infiniteList(143-144, the two render-mode grid handles),_deleteConfirm(145),_searchString(146). RoleText(role)(196):RoleLabel?.Invoke(role) ?? role.SaveFilters/RestoreFilters(208-213): persist and restore_searchStringunder the"search"key, the list-page filter-persistence contractDataGridListPageBase<TDto>defines.LoadServerData(227-243): routes toFetchPageviaInjectSearchFilterwhen supplied, otherwise toUsers.GetPagedAsyncwith the search term and the Role column's filter value.InjectSearchFilter(264): adds the free-text box to the filter bag underSearchFilterKey, but only when non-blank, so the delegate never has to distinguish "empty" from "absent".SearchTermFrom(278-281): prefers the free-text box; falls back to the Email column's own filter, so both affordances reach the one search parameter the administration endpoint takes.FilterValue(290): reads one column filter's value out of the grid's filter bag, ignoring its operator, since the endpoint takes a value only.ToggleLockAsync(296-322): confirms viaIAppDialogService, then callsActions.LockAsync/UnlockAsync, toasts, and reloads the active layout on success.ChangeRoleAsync(325-348): confirms, callsActions.SetRoleAsync(user.UserId, role), toasts, and reloads.DeleteUserAsync(352): routes throughListPageActions.DeleteWithConfirmationAsync, the shared delete-confirm-toast-reload sequence, callingOnDelete!(user)(the null-forgiving operator is safe here because the Delete button only renders whenOnDeleteis non-null).
- Why it's built this way: making
Usersa computed property resolved fromServiceProviderrather than an[Inject]field is what lets an app that suppliesFetchPageskip registeringIUserAdminUIService<TUser>at all; a required inject would force every consumer to register a service some of them never call. - Where it's used: wrapped by MMCA.ADC's
UserList.razor.cs(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/Users/UserList.razor.cs, 3 references), registered inMMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs, and referenced fromRoleAdminEdit.razor.csandRoleAdminList.razor.cs(sharedAdministrationPermissionsusage). Tested byMMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Administration/UserAdminListTests.cs(8 tests) andMMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.UI.Tests/Pages/Users/UserListTests.cs. - Caveats / not-in-source: the base class (
DataGridListPageBase<TDto>, inferred from theprotected overridemembers) is declared in the paired.razorfile, which this brief's source excerpt does not include; the inference is from member shape, not a visible@inheritsor base-list clause.
ConfirmationState
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/ConfirmEmail.razor.cs:50· Level 0 · enum (private, nested)
- What it is: the three states
ConfirmEmailcycles through while redeeming an email-confirmation link:Working,Form,Confirmed(:50-60). - Depends on: nothing; a private nested enum with no other type references.
- Concept: a private nested enum used purely as component render-state, the same shape used throughout the UI layer to drive conditional markup without a separate view-model class.
- Walkthrough:
Workingis the initial field value (:40) and stays current through the automatic redeem attempt inOnAfterRenderAsync(:108-128);Formcovers manual entry together with any resend notice or error message above it, and is the state chosen both up front, when neither an email nor a token is available to redeem (:122), and after a failed redeem (:181,:201);Confirmedmarks a successful redeem (:193). - Why it's built this way: a private enum keeps the state vocabulary scoped to the one component that interprets it, rather than promoting it to a shared UI-layer type nothing else needs. The vocabulary is three values, not four: a missing token, a failed redeem, and a completed resend all land the visitor back on the same manual-entry form, with only the
_errorMessage/_linkSentfields (not the enum) distinguishing which of those happened. - Where it's used: only inside
ConfirmEmail, which stores it in_stateand switches its markup on the value.
Login
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Login.razor.cs:8· Level 0 · class (partial, page code-behind)
- What it is: the code-behind half of the shared Login page. The markup half (
Login.razor) owns the form; this file owns the two behaviors around external sign-in: turning a refused OAuth round trip into words, and writing the per-attempt OAuth state before a provider redirect. - Depends on:
OAuthFlowStateStore(theOAuthFlowStateinjection,MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Login.razor:14),IStringLocalizer<SharedResource>asL(seeSharedResource); externals:[SupplyParameterFromQuery](Microsoft.AspNetCore.Components), BCLInvalidOperationException. - Concept introduced, a refusal code is a machine value that is mapped to words and never displayed.
[Rubric §27, Internationalization]assesses whether user-facing text translates;[Rubric §26, Front-End Security]assesses whether the front end leaks internals. The OAuth completion endpoint sends every refusal back to/login?error={code}, andError(Login.razor.cs:20-21, bound by[SupplyParameterFromQuery(Name = "error")]) receives it.ExternalSignInErrorMessage(:32-38) mapsnullor empty to no message,oauth_failedandmissing_claimsto their own resources, and any other code toRefusalMessage(:40-44). That helper looks upAuth.Login.ExternalError.{code}and, when the resource is missing (ResourceNotFound), falls back to the genericAuth.Login.ExternalError.Refusedtext. The doc comment (:24-29) explains the split: a code with a recovery path of its own (a locked account, an email already linked to another sign-in, an unverified provider email) gets its own resource, and every other domain code (for example an invalid name) shows the generic refusal. The raw code is never rendered. - Walkthrough:
_externalSignInUnavailable(:14): set when device storage dropped the OAuth attempt; the web-redirect provider buttons are then disabled, because a flow started there would be refused at completion (comment,:11-13).BeginOAuthAttemptAsync()(:52-70): awaitsOAuthFlowState.BeginAsync()(:56) and keeps the returned state in_oauthState. If the store throwsInvalidOperationException(:58), because storage accepted the write without keeping it (storage full, site data blocked, private mode), it sets_externalSignInUnavailable, showsAuth.Login.ExternalSignInStorageUnavailable, callsStateHasChanged, and returns (:60-64). Otherwise, when a state was written, it re-renders (:66-69).
- Why it's built this way: the completion page refuses a flow whose state it cannot find one redirect later, with no explanation; failing at the click, with the buttons disabled and a reason on screen, turns a silent dead end into an actionable message (doc comment,
:46-51). The fallback-to-generic rule means a new domain error code never surfaces as a raw key. - Where it's used: the
Login.razormarkup callsBeginOAuthAttemptAsyncafter first render when external providers exist (Login.razor:193,:180for_oauthState), and appends the state to the provider redirect query (Login.razor:211-213). Error mapping is covered byMMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Auth/LoginErrorQueryTests.cs, and the form models byLoginModel. The OAuth design is ADR-036 and the native callback is ADR-043. - Caveats / not-in-source: the markup half was read only for the cited lines; the form submit path and provider buttons live there, not here.
PasswordComplexityAttribute
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/PasswordComplexityAttribute.cs:15· Level 1 · class (sealed attribute)
- What it is: a custom
ValidationAttributethat enforces the framework's password-strength rule on any form that sets a new password: at least 8 characters including an uppercase, a lowercase, a digit, and a special (non-alphanumeric) character. - Depends on:
System.ComponentModel.DataAnnotations(ValidationAttribute,ValidationResult,ValidationContext, BCL) and the first-partyPasswordComplexityrule inMMCA.Common.Shared.Auth(MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/PasswordComplexity.cs:18). - Concept introduced, extending DataAnnotations with a domain rule defined once for both sides.
[Rubric §24, Forms, Validation & UX Safety]assesses client-side validation parity with the server. Beyond the built-in[Required]and[EmailAddress], a bespoke rule subclassesValidationAttributeand overridesIsValid, which plugs it straight into the same validator that drives the rest of the form. Parity is structural rather than copied: the attribute does not encode the character rules itself, it callsPasswordComplexity.Evaluate(PasswordComplexity.cs:52), the single Unicode-aware definition the server'sStrongPasswordRulesalso uses (doc comment, lines 6-13;PasswordComplexity.cs:5-17). That definition counts any\p{Lu}as uppercase, any\p{Ll}as lowercase, any\p{Nd}as a digit, and anything that is neither a letter nor a decimal digit as special (PasswordComplexity.cs:27-43), so an accented or CJK letter is a letter, never the special character. What happens to an accepted password server-side (PBKDF2-HMAC-SHA512 hashing with legacy-hash compatibility) is ADR-032; this attribute is only the client-side gate, never the security boundary. - Walkthrough:
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)](line 14), so it is applied as[PasswordComplexity]on a single property.- The constructor (lines 17-20) seeds the base
ErrorMessagewith the full English rule, so a usage that does not override the message still shows something actionable. The framework's own forms override it with anErrorMessageResourceType/ErrorMessageResourceNamepair that points atAuthFieldMessages.PasswordComplexity(seeRegisterModel). IsValid(object?, ValidationContext)(lines 22-36): one guard (line 24) returnsValidationResult.Successfor a non-string, a null/empty input, or a password for whichPasswordComplexity.Evaluateis true. Deferring the empty case toRequiredAttributekeeps the field to one message rather than two. On failure it returns aValidationResultscoped to the member name (lines 34-35) so the message attaches to the right input; the member lookup is null-conditional (validationContext?.MemberName, line 34) because the base class routes the context-freeIsValid(object)overload here with no context (comment, lines 29-30), and there is then no member to attach the error to.- The message is built with
FormatErrorMessage(validationContext?.DisplayName ?? string.Empty)(line 35), not the rawErrorMessageproperty (comment, lines 31-33):FormatErrorMessagealso resolves anErrorMessageResourceType/ErrorMessageResourceNamepair, which is how the auth models localize inside the attribute, and it returns a plainErrorMessageor the default message unchanged.
- Why it's built this way: because the rule is an attribute rather than page code, a second form that sets a password gets identical behavior by adding one line, which is exactly how the reset vertical picked it up. Delegating the predicate to a type in the Shared project, the one project both client and server may reference, is what makes the two verdicts identical by construction rather than by review. Delegating emptiness to
[Required]keeps one field from stacking two errors. The length cap is deliberately not part of this attribute:PasswordComplexity.MaximumLengthis a separate rule with its own message on both sides (PasswordComplexity.cs:24,:45-49). - Where it's used: applied to
RegisterModel.Password(RegisterModel,RegisterModel.cs:28) and toResetPasswordModel.NewPassword(ResetPasswordModel,ResetPasswordModel.cs:26); evaluated by theLocalizedDataAnnotationsValidatorinRegister.razor(MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Register.razor:33) andResetPassword.razor(MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/ResetPassword.razor:43). Unit-tested inMMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Auth/PasswordComplexityAttributeTests.cs:12; its agreement with the server rule is pinned by the architecture testMMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/Ui/PasswordRuleParityTests.cs:21. - Caveats / not-in-source: the doc comment (line 7) still describes the attribute as the rule "for the Register form" although the reset form carries it too; the code is the wider truth.
ForgotPasswordModel
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/ForgotPasswordModel.cs:15· Level 2 · class (sealed)
- What it is: the
EditFormbacking model for the Forgot Password page: oneEmailstring carrying DataAnnotations for shape validation. Nothing else is collected, because nothing else is needed to start a reset. - Depends on:
System.ComponentModel.DataAnnotations(BCL):[Required],[EmailAddress], and the first-partyAuthFieldMessagesresource type that supplies both messages. - Concept introduced, validation deliberately capped at "shape" because of an anti-enumeration contract.
[Rubric §24, Forms, Validation & UX Safety]assesses whether a form gives a clear per-field verdict before submit;[Rubric §26, Front-End Security]assesses whether the front end avoids leaking information the back end withholds. Every other form in this group validates as much as it can client-side. This one stops at "is this a syntactically valid address", because the interesting question, does an account exist for it, is one the server refuses to answer: ADR-091 Decision 3 has the reset request succeed on every path (malformed address, no account, throttled, failed send), so a distinguishable client-side outcome would reintroduce exactly the account-enumeration oracle the endpoint exists to avoid. The doc comment (ForgotPasswordModel.cs:7-8) states that trade-off directly. - Walkthrough: one
get; set;property.Email(line 19) carries[Required]and[EmailAddress](lines 17-18), each namingErrorMessageResourceType = typeof(AuthFieldMessages)withErrorMessageResourceName = nameof(AuthFieldMessages.EmailRequired)/nameof(AuthFieldMessages.EmailInvalid), and defaults tostring.Empty. The message is read from the shared resources inside the attribute (AuthFieldMessages,MMCA.Common/Source/Presentation/MMCA.Common.UI/Resources/AuthFieldMessages.cs:19, ADR-027), so the form's validator and MudBlazor's own field-level pass (which runs theForproperty's attributes when a field is touched) both show localized text, never a raw key (AuthFieldMessages.cs:11-17; model doc comment,ForgotPasswordModel.cs:10-12). - Why it's built this way:
sealedand mutable (set, notinit) becauseEditFormtwo-way-binds the input to the model; keeping the model to one field is what makes the page's anti-enumeration behavior easy to reason about, since there is no second field whose validation could betray a lookup. - Where it's used: instantiated as
_modelbyForgotPassword.razor(MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/ForgotPassword.razor:71) and bound by its<EditForm Model="_model" OnValidSubmit="HandleRequestAsync">plus<LocalizedDataAnnotationsValidator />(lines 38-39), with the field wiredFor="@(() => _model.Email)"(line 41) so the message attaches to that input. On valid submitHandleRequestAsync(lines 80-97) callsIAuthUIService.RequestPasswordResetAsync(_model.Email)(line 86, contract atMMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/IAuthUIService.cs:60) inside atrywhosecatchis empty on purpose (lines 88-91) and whosefinallysets_isSubmitted = trueunconditionally (line 95), so the confirmation renders for every submitted address whether the call succeeded, failed, or threw. - Caveats / not-in-source:
RequestPasswordResetAsyncreturns aResultand the call site never inspects it (line 86); the comment block above the method (lines 75-79) records that this is the anti-enumeration rule rather than a dropped result. The gallery E2E suite pins the behavior by asserting the confirmation appears with no backend at all (MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/Auth/ForgotPasswordPageE2ETests.cs:28), with WCAG 2.1 AA scans on both the form and the confirmation state (:41,:50).
LoginModel
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/LoginModel.cs:15· Level 2 · class (sealed)
- What it is: the
EditFormbacking model for the Login page: two string properties (Email,Password) carrying DataAnnotations for field-level validation. - Depends on:
System.ComponentModel.DataAnnotations(BCL):[Required],[EmailAddress]. The only first-party reference isAuthFieldMessages, the resource type its attributes read their messages from. - Concept introduced, the form-backing model plus a localizing DataAnnotations validator.
[Rubric §24, Forms, Validation & UX Safety]assesses whether forms validate at the field level with clear inline messages before submit;[Rubric §26, Front-End Security]assesses that client-side checks are a UX convenience, not the trust boundary;[Rubric §27, Internationalization]assesses whether validation messages translate. A BlazorEditFormbinds to a plain model, a<LocalizedDataAnnotationsValidator />reads the attributes and surfaces a per-field message as the user types, and the submit handler only fires on a valid form. Each message is read from the shared resources throughAuthFieldMessagesinside the attribute (doc comment,LoginModel.cs:10-12, ADR-027), so the model carries no English text and the localized text shows whether the form's validator or MudBlazor's own field-level pass runs the attribute. The doc comment (LoginModel.cs:7-8) is also explicit that the server remains the authority on whether the credentials are actually valid: the form only prevents an obviously malformed request. - Walkthrough: two
get; set;properties.Email(line 19),[Required]plus[EmailAddress](lines 17-18), each withErrorMessageResourceType = typeof(AuthFieldMessages)and the nameEmailRequired/EmailInvalid, defaulting tostring.Empty.Password(line 22),[Required]with the namePasswordRequired(line 21). There is deliberately no complexity rule here: login validates an existing credential, not a new one, and rejecting a legacy password client-side would lock a user out of their own account.
- Why it's built this way:
sealedand mutable becauseEditFormtwo-way-binds each input; each attribute names oneAuthFieldMessagesentry so each field shows exactly one verdict, in the reader's language. - Where it's used: instantiated as
_modeland bound byLogin.razor(<EditForm Model="_model" OnValidSubmit="HandleLoginAsync">plus<LocalizedDataAnnotationsValidator />,MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Login.razor:39-40, field at line 172 (not re-verified), inputs boundFor="@(() => _model.Email)"andFor="@(() => _model.Password)"at lines 48 and 54 so eachMudTextFieldshows its own message). On valid submit the page hands the credentials toIAuthUIServiceas aLoginRequest(Login.razor:241). Sibling ofRegisterModel; its shape rules are unit-tested alongside the other auth models inMMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Auth/AuthModelValidationTests.cs:11.
RegisterModel
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/RegisterModel.cs:16· Level 2 · class (sealed)
- What it is: the
EditFormbacking model for the Register page: name, email, and password fields with DataAnnotations, plus six optional address fields. - Depends on:
System.ComponentModel.DataAnnotations([Required],[StringLength],[EmailAddress],[Compare]), the sibling first-partyPasswordComplexityAttribute,AuthFieldMessagesfor every message, andPasswordComplexityfor the shared length cap. - Concept reinforced, multi-field form validation with a cross-field compare.
[Rubric §24, Forms, Validation & UX Safety]. This builds on theLoginModelshape with three richer rules:[PasswordComplexity]on the password (paired with a[StringLength(PasswordComplexity.MaximumLength)]cap, 128 atMMCA.Common/Source/Core/MMCA.Common.Shared/Auth/PasswordComplexity.cs:24, the constant the server rule shares),[Compare(nameof(Password))]on the confirmation (a cross-field equality check the validator resolves by property name), and an address block left attribute-free because it is optional. The doc comment (lines 8-13) notes the annotations mirror the server's rules so client and server agree, and that every message is read from the shared resources throughAuthFieldMessagesinside the attribute (ADR-027), so both the form's validator and MudBlazor's field-level pass show localized text. - Walkthrough: every attribute below names
ErrorMessageResourceType = typeof(AuthFieldMessages)plus anErrorMessageResourceName = nameof(AuthFieldMessages.X)entry.FirstName/LastName(lines 19, 22), each[Required]with its own entry,FirstNameRequired/LastNameRequired(lines 18, 21).Email(line 26),[Required]plus[EmailAddress](lines 24-25), entriesEmailRequired/EmailInvalid.Password(line 31),[Required](PasswordRequired) plus[StringLength(PasswordComplexity.MaximumLength)](PasswordMaxLength) plus[PasswordComplexity](PasswordComplexity) (lines 28-30).ConfirmPassword(line 35),[Required](ConfirmPasswordRequired) plus[Compare(nameof(Password))](ConfirmPasswordMismatch) (lines 33-34).AddressLine1plus nullableAddressLine2/City/State/ZipCode/Country(lines 38-43), with no validation attributes; the inline comment (line 37) states that an empty Line 1 means "no address supplied".AcceptedTerms(line 48), the "I agree to the Terms" box. It carries no validation attribute: it is required only when the host configures a Terms URL (LegalSettings.TermsUrl), and the page enforces that by keeping the submit button disabled until it is ticked (comment, lines 45-47).
- Why it's built this way: the address fields stay attribute-free so a user can register without supplying one; the model is a flat view-model that the page projects onto the wire DTO at submit time rather than reusing a domain type directly, which is what lets the optional-address rule live in page code instead of leaking into the contract.
- Where it's used: instantiated as
_modeland bound byRegister.razor(<EditForm Model="_model" OnValidSubmit="HandleRegisterAsync">plus<LocalizedDataAnnotationsValidator />,MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Register.razor:32-33, field at line 148). On valid submit the page projects it into aRegisterRequest(Register.razor:189), folding the address fields into anAddressthroughBuildAddressResult()(defined in theRegistercode-behind,Register.razor.cs:22), which returnsnullwhen all six are blank (lines 29-37) and otherwise callsAddress.Create(...)(line 39). Its password block is mirrored byResetPasswordModel; the accepted password is hashed server-side per ADR-032. Shape rules are unit-tested inMMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Auth/AuthModelValidationTests.cs:11; rendering and validation behavior are covered byMMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Auth/RegisterFormTests.cs:22and the gallery E2E suite (MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/Auth/RegisterPageE2ETests.cs:9).
ResetPasswordModel
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/ResetPasswordModel.cs:17· Level 2 · class (sealed)
- What it is: the
EditFormbacking model for the Reset Password page: the address and the emailed reset token that identify the request, plus the new password and its confirmation. - Depends on:
System.ComponentModel.DataAnnotations([Required],[StringLength],[EmailAddress],[Compare]), the sibling first-partyPasswordComplexityAttribute,AuthFieldMessagesfor every message, andPasswordComplexityfor the shared length cap. - Concept reinforced, the same password block as registration, on a credential-carrying form.
[Rubric §24, Forms, Validation & UX Safety]and[Rubric §26, Front-End Security]. The password half is exactly the shapeRegisterModelintroduced, which is the payoff of expressing the complexity rule as an attribute rather than page code. What is new is the top half:EmailandTokenare not values the user chooses, they are the credential the server minted and mailed. The client validates only that both are present and that the address is well formed; every substantive rejection (unknown, expired, mismatched, or attempt-capped token) collapses into one server-side error by design (ADR-091 Decision 3), so the form must not try to pre-judge a token it cannot verify. As on the sibling models, each message is read from the shared resources throughAuthFieldMessagesinside the attribute (doc comment, lines 12-14, ADR-027). - Walkthrough: four
get; set;properties, each defaulting tostring.Empty; every attribute namesErrorMessageResourceType = typeof(AuthFieldMessages)plus anErrorMessageResourceNameentry.Email(line 21),[Required]plus[EmailAddress](lines 19-20), entriesEmailRequired/EmailInvalid.Token(line 24),[Required]with entryTokenRequired(line 23) and nothing more: length, encoding, and freshness are all properties of the server-side cache record.NewPassword(line 29),[Required](PasswordRequired) plus[StringLength(PasswordComplexity.MaximumLength)](PasswordMaxLength) plus[PasswordComplexity](PasswordComplexity) (lines 26-28).ConfirmPassword(line 33),[Required](ConfirmPasswordRequired) plus[Compare(nameof(NewPassword))](ConfirmPasswordMismatch) (lines 31-32), the cross-field check retargeted atNewPassword.
- Why it's built this way: the doc comment (lines 7-10) records the load-bearing choice, that
EmailandTokenarrive prefilled from the reset link but stay editable, so a user who only has the raw token text from the email (the situation on a native head with no working deep link) can paste it by hand. Making those two ordinary bound fields rather than read-only parameters buys that fallback for free. - Where it's used: instantiated as
_modelbyResetPassword.razor(MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/ResetPassword.razor:105) and bound by its<EditForm Model="_model" OnValidSubmit="HandleResetAsync">plus<LocalizedDataAnnotationsValidator />(lines 42-43). The model is prefilled from two sources, both of which fill a field only when it is still blank so a value the user corrected by hand is not overwritten. First, the page declares[SupplyParameterFromQuery]EmailandTokenproperties (lines 99-103) and copies them inOnParametersSet(lines 112-123, blank checks at 114 and 119). Second,OnAfterRenderAsync(lines 130-177) reads the URL fragment once on first render through thelocationHashTakeJS call (line 143), which also scrubs it from the address bar, and fillsemail/tokenfrom it (lines 161-170); the comment at lines 125-129 explains why: a fragment is never sent to a server, so the live token stays out of ingress logs and request telemetry, while the query-string path keeps older links working.HandleResetAsync(lines 179-204) callsIAuthUIService.ResetPasswordAsync(_model.Email, _model.Token, _model.NewPassword)(line 186, contract atMMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/IAuthUIService.cs:67), flips_isCompletedon success, and on failure rendersresult.LocalizedErrorMessage(L)or the genericAuth.Reset.GenericErrorstring (line 193), which a thrown call also falls back to (lines 196-199). The prefill path is pinned by a gallery E2E test (MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/Auth/ResetPasswordPageE2ETests.cs:31), with a WCAG 2.1 AA scan alongside it (:43). - Caveats / not-in-source: the model has no rule tying
Tokento the address; that pairing is enforced by the server's cache record (ADR-091 Decision 1), not by anything visible here.
ConfirmEmail
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/ConfirmEmail.razor.cs:36· Level 4 · class (sealed, partial, page code-behind)
- What it is: the shared, framework-provided landing page for the emailed confirmation link. It reads the address and token from the query string or a URL fragment, attempts to redeem them automatically on first render, and falls back to manual entry plus a resend option when redemption fails.
- Depends on:
IEmailConfirmationUIService(injected asConfirmationService,:70),CapabilitiesJsModule(the optional JS interop module used to read the URL fragment, resolved throughIServiceProviderat:135),SharedResource(asIStringLocalizer<SharedResource>,:74),ComponentLifetimeExtensions.LifetimeToken(MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/ComponentLifetimeExtensions.cs:26), the private nestedConfirmationState(:50-60); externals:[SupplyParameterFromQuery],[Inject],ComponentBase(Microsoft.AspNetCore.Components), BCLSystem.Uri,System.StringComparison,System.Threading.CancellationToken. - Concept: the component accepts the email and token two ways: as ordinary query parameters (
EmailFromQuery,TokenFromQuery,:62-68), or as a URL fragment read client-side throughCapabilitiesJsModule(TakeFragmentAsync,:130-140). A URL fragment is never sent to the server in the request line, so a host that wants the token to never touch server logs can mint links that carry it there instead; the query-parameter path stays as a fallback.ApplyFragment(:144-171) only fills a field that is still empty (:162,:166), so a query value already present is never overwritten by the fragment. - Walkthrough: nine private fields track render state (
_cts,_state,_email,_token,_errorMessage,_linkSent,_isBusy,_attempted,_disposed,:38-47), and two properties are query-bindable via[SupplyParameterFromQuery](EmailFromQuery,:63-64;TokenFromQuery,:67-68).Dispose()(:77-87): idempotent, cancels then disposes_cts.OnParametersSet()(:94-105) only fills_email/_tokenfrom the query parameters when the field is still empty, so a value the visitor corrected by hand survives a re-render.OnAfterRenderAsync(firstRender)(:108-128) runs the redeem attempt exactly once (_attempted,:110-115): it reads the fragment (:116), and if the email or the token is still missing after that, sets_state = ConfirmationState.Formand returns without ever calling the confirmation service (:118-125), so a bare visit to the page (no link data at all) goes straight to manual entry instead of posting a request that can only fail.TakeFragmentAsync()(:130-140) resolvesCapabilitiesJsModulefromServicesand returnsnullwhen it is absent (:137-139); the comment above it notes this happens during SSR prerender and in a host without the capabilities module, and its absence is treated as normal, not an error.ConfirmAsync()(:173-215) posts throughConfirmationService.ConfirmEmailAsync(_email.Trim(), _token.Trim(), _cts.LifetimeToken())(:190) and sets_statetoConfirmed(:193) on success, orFormwith_errorMessageset to the API's own wording viaresult.LocalizedErrorMessage(L)on failure (:200-201), wrapped in_isBusy/StateHasChangedbracketing so the UI can show a spinner. The token comes fromLifetimeToken()rather than the raw_cts.Token, the repo convention for component cancellation sources.OperationCanceledExceptionis swallowed as expected during disposal or anInteractiveAutorender-mode transition (:203-206).ResendAsync()(:217-251) callsConfirmationService.ResendEmailConfirmationAsync(_email.Trim(), _cts.LifetimeToken())(:232) and sets_linkSent = trueon success, never changing_state; the endpoint answers 202 whether or not the address holds an account, so the page reports the same thing either way, the same anti-enumeration rule theForgotPasswordModelflow uses.
- Why it's built this way: separating the automatic redeem attempt from the manual/resend UI means a stale, already-used, or malformed link degrades to a working form instead of a dead end, and the fragment-vs-query split lets a deployment choose whether the token transits the server's own request logs. Collapsing resend and redeem-failure onto the same
Formstate (rather than distinct states) keeps_statepurely about which panel renders, while_errorMessageand_linkSentcarry the finer detail. - Where it's used: reached wherever a host maps the framework's shared Auth pages. Exercised by the shared conformance test base
MMCA.Common/Source/Hosting/MMCA.Common.Testing.UI/Pages/ConfirmEmailPageTestsBase.cs, and backed downstream by ADC's confirm-email domain and application workflow thatIEmailConfirmationUIServiceultimately calls through the API (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ConfirmEmail/ConfirmEmailHandler.cs,ConfirmEmailCommand.cs,MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IEmailConfirmableUser.cs). - Caveats / not-in-source: the paired
.razormarkup (not read here) is what actually renders the states; this code-behind only manages the state transitions. Which query names ("email", "token") a real confirmation email uses is set by whatever composes the link server-side, not by this component.
Register
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Register.razor.cs:10· Level 4 · class (partial, page code-behind)
- What it is: the code-behind half of the shared Register page. It holds one decision the markup needs: whether the user supplied an address, and if so whether it is a valid one.
- Depends on:
RegisterModel(the_modelfield the markup declares),RegistrationSettings(read throughRegistrationOptions.Value.CollectAddress,Register.razor.cs:13), and the first-partyAddressvalue object plusResult(Register.razor.cs:22,:39). - Concept introduced, an optional block that is validated if touched.
[Rubric §24, Forms, Validation & UX Safety]assesses whether a form drops or silently accepts bad input.BuildAddressResult()(:22-40) returnsResult<Address>?with three outcomes. With address collection off (CollectAddress,:13) it returnsnull, because there is no block to type into (:24-27). With collection on and all six fields blank (:29-37) it also returnsnull: no address is legitimate. Otherwise it callsAddress.Create(...)(:39) and returns thatResult, so anything the user did type is validated and an invalid address blocks the registration. The doc comment (:15-21) records the reason: the account used to be created with no address at all when the typed address was invalid. - Walkthrough: one computed property (
CollectAddress,:13) and one method (BuildAddressResult,:22). The markup calls it on valid submit and projects the success value into aRegisterRequest, as described underRegisterModel. - Why it's built this way: returning a nullable
Resultrather than a bareAddress?is what lets "nothing entered" (null) stay distinct from "entered and invalid" (a failedResult), which a plain null could not express. - Where it's used: bound by
Register.razor; behavior is covered byMMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Auth/RegisterFormTests.cs,RegisterAddressOptionTests.csandRegisterTermsTests.csin the same folder. - Caveats / not-in-source: the Terms checkbox enforcement lives in the markup half, not in this file; only the address rule is here.
Sessions
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Sessions.razor.cs:27· Level 6 · class (partial, page code-behind)
- What it is: the code-behind for the signed-in devices page at
/profile/sessions: one row per live refresh session, with a per-device sign-out and a sign-out-everywhere, each behind a confirmation dialog. - Depends on:
IAuthUIService(line 29),IToastService(line 31),IAppDialogService(line 32),SharedResourceasIStringLocalizer<SharedResource>(line 33),ViewerTimeZone(line 34),RefreshSessionSummaryResponse(the row DTO, line 41),Result(line 44),ResultUiExtensions(IsNotFound,NotifyOnFailure),UserAgentSummary(line 239),ComponentLifetimeExtensions.LifetimeToken(MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/ComponentLifetimeExtensions.cs:26), andRoutePaths(line 68). Externals: Blazor'sNavigationManagerand MudBlazor'sBreadcrumbItem/MudTable. - Concept introduced, two revoke paths with deliberately different endings.
[Rubric §11, Security]assesses whether a user can see and end their own live credentials;[Rubric §25, Navigation & IA]assesses whether a destructive action leaves the app in a coherent place. The class comment (lines 16-24) states the model: a row's button calls the per-session revoke, which ends one other device's session and leaves this one alone, while the page-level button is the account-wide revoke, which also ends the session the caller is using and therefore must be followed by the local sign-out and a redirect, but only once the server confirmed the revoke (line 21). The consequence is visible in the markup: the row for the current device offers no button at all (Sessions.razor:68-75), because revoking it from a row would leave the app signed in on a dead session until the access token expired, which reads to a user as a broken sign-out. - Concept introduced (2), a single in-flight gate over two different operations.
[Rubric §18, UI Architecture & Component Design].IsBusy(line 58) is_revokingSessionId is not null || IsRevokingAll, and every button in the markup reads it (Sessions.razor:80,:97). One flag over both operations is what stops a second click from starting a concurrent revoke while the list is about to be rebuilt underneath it, and_revokingSessionIddoubles as the per-row spinner selector (Sessions.razor:84). Both revoke handlers checkIsBusytwice, before and after the awaited confirmation dialog (lines 124 and 135, 189 and 200), because another operation can start while the dialog is open. - Walkthrough:
- State (lines 36-61): a component-scoped
CancellationTokenSource(line 38) passed into every service call (as_cts.LifetimeToken(), the repo convention) and cancelled on dispose,_breadcrumbs,_sessions, the nullable_loadResultthat carries the last load outcome for inline rendering (line 44),_revokingSessionId,IsLoading(line 52),IsBusy(line 58), andIsRevokingAll(line 61).LoginRouteis aconst(line 36). OnInitializedAsync(lines 63-73): builds the breadcrumbs here rather than in a field initializer so the injected localizer is available (comment at line 65, ADR-027), then loads.OnAfterRenderAsync(firstRender)(lines 76-83): on first render, while not disposed, awaitsViewerTime.EnsureResolvedAsync(_cts.LifetimeToken())and callsStateHasChangedwhen it resolved a zone (line 81). Session times render on the viewer's clock, and the browser's zone is only readable once JS is available, so the page re-renders once the zone is known. SeeViewerTimeZone.LoadSessionsAsync(lines 85-114): stores the wholeResultin_loadResult(line 93) so the markup can render the failure inline with a retry button rather than as a toast (Sessions.razor:17,:23-30; the comment atSessions.razor:15-16explains why: an empty table and a failed load look identical once a toast expires). On failure it empties_sessionsdeliberately (lines 99-104), so a stale device list can never be left on screen for a user to act on.OperationCanceledExceptionis swallowed as expected-on-disposal (lines 106-109) andIsLoadingclears in thefinally.RevokeSessionAsync(session)(lines 122-178): refuses to run while busy or for the current device (lines 124-127, a second guard behind the markup's), then asks for confirmation throughIAppDialogService.ConfirmAsyncwith a message naming the device viaDescribeDevice(lines 129-133), because the device loses its session at once (doc comment, lines 116-121). After the post-dialog re-check (lines 135-138) it marks the row in flight (line 140) and callsStateHasChanged(line 145): the dialog completed asynchronously, so Blazor has already rendered once for the click and will not render again until the handler returns, which would otherwise leave the button showing no progress for the length of the revoke call (comment, lines 142-144). It then treats three outcomes distinctly. Success toasts (line 153). A not-found result, which means the session is already revoked (the server answersAuth.SessionAlreadyRevokedafter a duplicate click or once the device signed itself out) or no longer exists, toasts at info severity instead (lines 155-161), because the user's intent is satisfied and there is nothing to correct. Any other failure goes throughresult.NotifyOnFailure(Toast, L)and returns without reloading (lines 162-166). On either satisfied outcome it reloads the list from the server rather than removing the row locally (line 168), because the server is the authority on what is still live and a reload also catches a session that expired while the page sat open.RevokeAllAsync(lines 187-230): guards onIsBusy(line 189), confirms throughConfirmAsync(lines 194-198) because this also ends the session the user is on, re-checks (line 200), renders the busy state before the long call (StateHasChanged, line 208, same reasoning as the per-device revoke), then callsIAuthUIService.RevokeAllSessionsAsync(_cts.LifetimeToken())(line 212, contract atMMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/IAuthUIService.cs:94), which is the account-wide revoke followed, on success, by the local sign-out. Only on success does it navigate to/loginwithforceLoad: true(line 215), so the circuit and any cached client state are rebuilt rather than kept alive against a revoked identity. A failed revoke is shown throughresult.NotifyOnFailure(Toast, L)(line 219) and the page stays put: the doc comment (lines 180-186) explains that a best-effort logout would claim every device was signed out even when the server refused.OperationCanceledExceptionis swallowed as expected-on-disposal (lines 222-225) andIsRevokingAllclears in thefinally(lines 226-229).DescribeDevice(session)(lines 237-248): parses browser and platform out of the user agent viaUserAgentSummary.Parse(MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/UserAgentSummary.cs:66) and composes them through a resource format string in the both-known case (line 243), so the word order translates rather than being concatenated in English order; a single known part is returned as is, and neither known falls back to an explicit "unknown device" string (line 246).[Rubric §27, Internationalization]. The same label feeds the per-row confirmation message andaria-label.FormatInstant(DateTime)(line 255, doc comment lines 250-254): delegates toViewerTime.Format(instant). It formats in the viewer's browser time zone and current culture, because the endpoint reports UTC and "signed in at 03:14" only means something on the clock the reader uses;DateTime.ToLocalTimewould be the server's zone on Blazor Server and in prerender, which is why the method is no longer a static local-time conversion.Dispose(bool)/Dispose()(lines 257-277): the standard idempotent pattern, cancelling and disposing the CTS.
- State (lines 36-61): a component-scoped
- Why it's built this way: rendering the load failure inline instead of as a toast is a deliberate
[Rubric §24, Forms, Validation & UX Safety]choice for a page whose empty state is indistinguishable from its failure state, and it is the same reasoningDataGridListPageBase<TDto>encodes in itsLoadFailedflag. Treating "already revoked" as an informational outcome rather than an error avoids punishing a user for a double click on an idempotent action. Confirming both revokes before acting is the same section's destructive-action rule: each one signs a device out immediately. - Where it's used: routed at
/profile/sessionsbehind[Authorize]with[StreamRendering(false)](MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Sessions.razor:1-6), reachable in any host that maps the framework's UI pages. Its component behavior is covered by bUnit tests (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Auth/SessionsTests.cs:27) and its rendered accessibility by the gallery E2E WCAG 2.1 AA scan (MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/Auth/SessionsPageE2ETests.cs:21). - Caveats / not-in-source: whether
RevokeAllSessionsAsynccompletes the local sign-out when the server call succeeds but token clearing fails is a property ofIAuthUIService, not of this file; any exception other thanOperationCanceledExceptionstill propagates out of both handlers with no toast. The accessibility markers the page relies on (the text-variant "this device" chip atSessions.razor:58-59, chosen so the marker does not depend on color alone, and the per-buttonaria-labels at:83and:100) live in the markup half, not in this code-behind.
CachedPage
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/OfflineFirstPageSnapshot.cs:39· Level 0 · record (sealed, private, nested)
- What it is: the on-disk shape of an offline list snapshot, a two-field record
(List<TItem> Items, int TotalItems)nested privately insideOfflineFirstPageSnapshot<TItem>. It is what actually gets serialized when a list page remembers its first page for a dead network. - Depends on: nothing first-party. It is round-tripped through
ILocalCacheStore, whoseSetAsync/GetAsync<T>do the JSON work (OfflineFirstPageSnapshot.cs:44-45,:63). - Concept introduced, the private nested cache payload.
[Rubric §19, State Management & Data Flow]assesses whether client-held state has an explicit, owned shape rather than being smeared across ad-hoc dictionaries;[Rubric §29, Resilience & Business Continuity]assesses whether a surface degrades instead of failing when a dependency is gone. Declaring the payload as aprivate sealed recordinside the only type that reads and writes it makes the snapshot format an implementation detail: no consumer can take a dependency on the field names, so the shape can change without a public-API break. The trade-off is the flip side of that: because the format is private and unversioned, a shape change silently orphans whatever is already in the device store. - Walkthrough: one line.
private sealed record CachedPage(List<TItem> Items, int TotalItems);(line 26). Written byRememberAsync, which materializes the fetched rows into a fresh list with a collection expression,new CachedPage([.. fetched.Items], fetched.TotalItems)(line 44), so the cached copy is decoupled from the caller's live list. Read back byTryReadAsyncasstore.GetAsync<CachedPage>(cacheKey, cancellationToken)(line 63) and immediately destructured into the tuple the grid expects,(cached.Items, cached.TotalItems)(line 64). - Why it's built this way: a
recordgives value semantics and a positional constructor for free, which is all a serialization payload needs;List<TItem>rather thanIReadOnlyList<TItem>is the concrete collection the round-trip materializes into. Nesting it privately keeps the type out of the package's public surface entirely. - Where it's used: only inside
OfflineFirstPageSnapshot<TItem>. Its round-trip is covered end to end byMMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Common/OfflineFirstPageSnapshotTests.cs:15. - Caveats / not-in-source: the doc comment states that
TItem"must be JSON round-trippable" (OfflineFirstPageSnapshot.cs:14), but nothing in this file enforces that; a DTO the store's serializer cannot handle fails at runtime, not at compile time.
ErrorMessages
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/ErrorMessages.cs:24· Level 0 · class (static)
- What it is: a small factory of user-facing failure strings (load, save, delete, delete-failed, not-found, validation) so every page code-behind reports an outcome with identical, culture-correct phrasing, resolved through a shared localizer once one is configured (ADR-027).
- Depends on:
IStringLocalizer/LocalizedString(Microsoft.Extensions.Localization, NuGet) andstring.FormatwithCultureInfo.CurrentCulture(BCL). No first-party types at all. The localizer it is handed is anIStringLocalizer<SharedResource>(doc comment,ErrorMessages.cs:32), so it shares theSharedResource.resxkeys. - Concept introduced, the static helper back-filled with an injected localizer, and the "never show raw exception text" rule.
[Rubric §27, Internationalization]assesses whether user-facing copy resolves per UI culture from resources instead of being hard-coded English;[Rubric §15, Best Practices & Code Quality]assesses whether a wording change lands in one place;[Rubric §24, Forms, Validation & UX Safety]assesses that internal error text never leaks to the user. The mechanism is the interesting part: the API isstatic, so any page can callErrorMessages.LoadError(Title, ex)without taking a DI dependency, yet the output is culture-aware because the root layout hands the class one shared localizer at startup. Every method routes through a privateLocalize(key, fallbackFormat, args)that returns the resource value when the localizer is set and the key resolves, and the inline English format string otherwise. The scope note in the class comment (lines 14-22) is what pins the responsibility boundary: a server answer reaches a page as aResultand is rendered byResultUiExtensions(NotifyOnFailure,OnFailureSetError), so these helpers only cover the exceptions a page can still see, which are its own faults (a JS-interop failure, a mapping bug, a callback the page supplied). Such an exception'sMessageis never rendered: raw exception text is neither localizable nor safe to surface (ADR-027 Decision 9). - Walkthrough: one mutable static field plus pure builders.
_localizer(line 26), a nullableIStringLocalizer?, null until configured.Configure(IStringLocalizer localizer)(line 33), the single wiring point: an expression-bodied assignment, idempotent, called once from the root layout.Localize(key, fallbackFormat, args)(lines 35-47), the resolution core: when_localizeris set and the lookup'sResourceNotFoundis false it returnslocalized.Value(lines 37-44); otherwisestring.Format(CultureInfo.CurrentCulture, fallbackFormat, args)(line 46).LoadError/SaveError/DeleteError(lines 56-57, 60-61, 64-65), the three CRUD failure paths, keyedCommon.Error.Load/Save/Delete. Each passes the entity name andex.Messageas format arguments, and the shipped templates deliberately ignore the second one (doc comment, lines 49-55), so the exception text is available to a resource that wants it while the shipped copy never prints it. The two siblings carry<inheritdoc cref="LoadError"/>(lines 59, 63) rather than repeating the rationale.DeleteFailed(string entityName)(lines 67-68, keyCommon.Error.DeleteFailed), the "the call returned but the delete did not happen" case, distinct fromDeleteError, which carries an exception.NotFound(string entityName, object id)(lines 70-71, keyCommon.Error.NotFound), interpolating the entity name and the missing id.ValidationError(lines 73-74, keyCommon.Error.Validation), a parameterless property and the only fixed sentence.
- Why it's built this way: keeping the API static means call sites never move, while the
Configureindirection adds localization without a signature change anywhere. The uniform "template only" answer is what makes the class safe to call from anycatch: there is no branch on exception type, so no curated-message path can accidentally become a leak path. The mutable static is a deliberate, single exception to the framework's no-static-state rule and is named explicitly in the architecture fitness allowlist (MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/Ui/StateManagementConventionTests.cs:22, with the reasoning at lines 16-21: write-once wiring, not per-user state). - Where it's used: configured once per host by
ErrorMessages.Configure(L)in the root layout (MMCA.Common/Source/Presentation/MMCA.Common.UI/Layout/MainLayout.razor:103). Called byDataGridListPageBase<TDto>on the two non-Resultfailure paths (DataGridListPageBase.cs:751paged,:665virtualized,:767mobile), byNotificationSend(MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Notifications/NotificationSend.razor.cs:127), and by the Store entity pages forNotFoundandValidationError(for exampleMMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.UI/Pages/Products/ProductDetail.razor.cs:86and:200). - Caveats / not-in-source: the
.resxpayloads (SharedResource.resx,SharedResource.es.resx) are resources, not.cs, so per-key contents are not enumerable here; a shipped template that did consume{1}would print the exception text, and only the unit tests pin that it does not (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Common/ErrorMessagesTests.cs:11, including the explicit case that even aDomainInvariantViolationExceptiongets the plain template,:45).
PersistedGridState
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DataGridListPageBase.cs:1132· Level 0 · record (sealed, private, nested)
- What it is: a tiny serializable record
(List<TDto> Items, int TotalItems)that carries the grid's already-fetched rows from the SSR pre-render pass into the interactive circuit, so the first interactiveServerDatacall can answer instantly instead of re-hitting the API. - Depends on:
Microsoft.AspNetCore.Components.PersistentComponentState(the Blazor mechanism that serializes it). Nested privately insideDataGridListPageBase<TDto>. - Concept introduced,
PersistentComponentStateto skip the double fetch.[Rubric §19, State Management & Data Flow]and[Rubric §23, Front-End Performance & Rendering]assess whether redundant work is avoided across render-mode transitions. Under InteractiveAuto a page renders more than once (static SSR, then interactive Server, then WebAssembly), and naively each transition re-runs the data fetch, which the user sees as a fetch-cancel-refetch flicker. Blazor'sPersistentComponentStateserializes chosen data into the pre-rendered HTML and rehydrates it in the interactive circuit;PersistedGridStateis the payload for the grid's data slice, so that cycle disappears. - Walkthrough: declared as
private sealed record PersistedGridState(List<TDto> Items, int TotalItems)(line 1034) at the very bottom of the file, under a doc comment (lines 1030-1033). On the persisting side, the callback registered inOnInitializedwritesnew PersistedGridState([.. _lastSuccessfulGridData.Items], _lastSuccessfulGridData.TotalItems)(line 189) under the keygrid:{GetType().FullName}(built at line 171), and only when a successful fetch has actually happened (line 187). On the restoring side, the synchronousOnInitializedcallsApplicationState.TryTakeFromJson<PersistedGridState>(persistKey, out var restored)(line 172) and, when present, rebuilds aGridData<TDto>into_persistedGridData(line 174) that the firstLoadServerDataAsyncreturns directly (lines 513-522). - Why it's built this way:
privatebecause the persistence is purely an implementation detail of the base class; asealed recordfor JSON friendliness and value semantics; the items are materialized into a freshList<TDto>with a collection expression (line 189) so the persisted snapshot is decoupled from the live grid data. - Where it's used: exclusively inside
DataGridListPageBase<TDto>, so every derived list page inherits the behavior with no wiring of its own. - Caveats / not-in-source: the persisting callback is registered with an explicit
Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveAuto(line 194) to satisfy the framework's "callback must be associated with a render mode" rule during the static prerender pass, because the page inherits its render mode from<Routes @rendermode="InteractiveAuto">rather than declaring one itself; the inline comment (lines 177-183) quotes the exact framework error this avoids. The restore runs in the synchronousOnInitialized, before any async lifecycle work.
LazyJsModule
MMCA.Common.UI ·
MMCA.Common.UI.Services·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/LazyJsModule.cs:20· Level 0 · class (internal sealed)
- What it is: a small single-flight importer that owns one JavaScript module reference for a UI service: it imports on first use, shares one import across concurrent callers, and disposes the reference safely when the circuit ends.
- Depends on:
Microsoft.JSInterop(IJSRuntime,IJSObjectReference,JSDisconnectedException) and the .NETSystem.Threading.Locktype. No first-party dependencies. - Concept introduced, single-flight JS module import.
[Rubric §23, Front-End Performance]assesses whether the client loads only what it needs, when it needs it; deferringimport()until first use is the lazy half of that, and collapsing concurrent imports into one is the correctness half. The class comment (LazyJsModule.cs:5-13) states the exact defect this replaces: an unguarded_module ??= await import(...)lets two concurrent callers each start an import, after which the browser holds two module instances and the later assignment leaks the earlier reference, which is never disposed.[Rubric §15, Best Practices & Code Quality]applies to the second half of the design: a failed import is dropped rather than cached, so an import attempted during SSR prerender (when JS interop does not exist yet) does not poison the module for the rest of the circuit. - Walkthrough
- The primary constructor takes the
IJSRuntimeand the module path (LazyJsModule.cs:20). State is three fields: aLock(LazyJsModule.cs:22), the in-flight import task (LazyJsModule.cs:24) and the resolved module (LazyJsModule.cs:25).IsImported(LazyJsModule.cs:28) exists so disposal can skip work. GetOrImportAsync(LazyJsModule.cs:37) starts with a lock-free fast path returning the cached module (LazyJsModule.cs:39-42), then takes the lock only to publish or read the in-flight task (LazyJsModule.cs:47-51); the inline comment (LazyJsModule.cs:44-45) notes why holding a lock there is safe, sinceImportAsyncreaches its first await immediately and nothing slow runs under it. The import is started with no caller token at all (LazyJsModule.cs:49), and each caller then awaits the shared task throughWaitAsync(cancellationToken)(LazyJsModule.cs:55), so a caller's token cancels only that caller's wait, never the import. The method summary (LazyJsModule.cs:30-36) gives the reason: binding the import to the first caller's token would let that one caller's cancellation fault the import for every concurrent awaiter.- The
finallyblock is the subtle part (LazyJsModule.cs:57-73): it clears the field only when the task has completed and did not complete successfully (LazyJsModule.cs:63), and only when the field still holds this task (LazyJsModule.cs:67), because clearing unconditionally could drop a newer import started after this one completed and split the next set of callers. The inline comment (LazyJsModule.cs:59-62) covers the cancellation case: a caller that stopped waiting on its own token leaves the import still running rather than failed, so it stays cached for the others. ImportAsync(LazyJsModule.cs:76-84) performs the actualjs.InvokeAsync<IJSObjectReference>("import", CancellationToken.None, modulePath)(LazyJsModule.cs:78-80) and assigns_module(LazyJsModule.cs:82).DisposeAsync(LazyJsModule.cs:87-104) returns immediately when nothing was imported (LazyJsModule.cs:89-92), nulls the field before awaiting (LazyJsModule.cs:94), and swallowsJSDisconnectedException(LazyJsModule.cs:100-103), since a torn-down circuit is the normal end of life for a scoped UI service.
- The primary constructor takes the
- Why it's built this way: the remarks (
LazyJsModule.cs:14-19) draw the responsibility line. This class deliberately does not swallow anything on the import path, so each consuming service keeps its own degradation contract (return a default, fall back to a navigation, no-op). That is why ListPageStateService wraps its calls in catch-and-ignore blocks while MmcaCultureBootstrap, which does not use this type at all, imports its module directly under anawait using. - Where it's used: ThemeService (
ThemeService.cs:20), ListPageStateService (ListPageStateService.cs:69), NavigationHistoryService (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Navigation/NavigationHistoryService.cs:16) and CapabilitiesJsModule (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Capabilities/CapabilitiesJsModule.cs:19). - Caveats / not-in-source: it is
internal(LazyJsModule.cs:20), so it is not part of the published package surface: consumer apps get the benefit through the services that use it, not by using it directly.
ListPageState
MMCA.Common.UI ·
MMCA.Common.UI.Services·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ListPageStateService.cs:9· Level 0 · record (sealed)
- What it is: the immutable snapshot of everything a list page needs to look the same after you navigate away and come back: which page, how many rows, how far down, which sort, which density and which filters.
- Depends on: nothing first-party. It is the currency shared by
ListPageStateService (in-memory plus
sessionStorage), ListPageQueryStateService (URL encoding) and DataGridListPageBase<TDto> (the consumer). - Concept introduced, one state shape over three transports.
[Rubric §19, State Management & Data Flow]assesses whether UI state has a single defined shape rather than being reconstructed ad hoc per page; this record is that shape, and the fact that memory, session storage and the address bar all move the same record is what keeps the three from drifting. The record is documented as update-by-with(ListPageStateService.cs:5-7), so a caller changing scroll position cannot accidentally reset paging. - Walkthrough
- Eight
initmembers, all with defaults, sonew ListPageState()is a valid pristine state. Page(ListPageStateService.cs:12) is the MudDataGrid 0-indexed page;PageSize(ListPageStateService.cs:15) the chosen rows per page;MobilePage(ListPageStateService.cs:18) the 1-indexed card-list page, and it is the only member with a non-default default (= 1), because a mobile page zero does not exist.ScrollPosition(ListPageStateService.cs:26) is adoubleof pixels, and its doc comment (ListPageStateService.cs:20-25) names which element it measures: the document (document.scrollingElement.scrollTop) for a normal paged list page, and the grid's own height-bound viewport (.mud-table-container) for a page that opts into grid virtualization, where the document itself does not scroll.SortColumn(ListPageStateService.cs:32) holds theSortByproperty name of the active sort definition and is null or empty when unsorted;SortDescending(ListPageStateService.cs:38) is documented as ignored when it is.DenseGrid(ListPageStateService.cs:46) carries the compact-density opt-in, persisted alongside paging and sort so the chosen density survives navigation, refresh and shared links.Filters(ListPageStateService.cs:52) is anIReadOnlyDictionary<string, string>of page-specific named values (the doc gives"search"and"status"as examples) defaulting to an empty dictionary, so each page decides what it saves.
- Eight
- Why it's built this way: a sealed record gives value equality and
with-based copies for free, which is what makes the "update only scroll position" and "update only density" paths in the services one-liners. - Where it's used: produced and consumed by both list-page state services and by
DataGridListPageBase<TDto> (
DataGridListPageBase.cs:430andDataGridListPageBase.cs:434); serialized tosessionStorageas JSON and encoded into the query string.
MudTranslations
MMCA.Common.UI ·
MMCA.Common.UI.Resources·MMCA.Common/Source/Presentation/MMCA.Common.UI/Resources/MudTranslations.cs:10· Level 0 · class (sealed)
- What it is: an empty marker class that anchors a
.resxresource pair for MudBlazor's own built-in component text: the data-grid pager and filter menus, pickers, table editing, pagination, snackbar and alert close buttons, and input adornments (ADR-027). - Depends on: nothing first-party. The type has no members: it is the single declaration
public sealed class MudTranslations;(line 10). Its meaning comes from its co-located resources, whose keys mirror MudBlazor's ownLanguageResourcekeys (v9.6.0) with the English values copied verbatim so en-US behavior is unchanged, and fromResxMudLocalizer, which injectsIStringLocalizer<MudTranslations>and hands those strings to MudBlazor's localization interceptor. - Concept reinforced, the resource-anchor type. The idiom is introduced in full at
SharedResource: ASP.NET Core'sIStringLocalizer<T>resolves keys against the.resxwhose base name matchesT, so a dedicated empty class becomes the name of a shared string table.MudTranslationsis the second anchor, scoped to third-party chrome rather than app chrome.[Rubric §27, Internationalization]assesses whether all user-visible copy follows the active culture, including the component library's;[Rubric §20, Design System & Theming]assesses a coherent design system, and a pager that still reads "Rows per page" under anesUI would break that coherence at exactly the surface the user interacts with most. - Walkthrough: there are no members. The whole contract is "be a public sealed type named
MudTranslationsin this namespace, with sibling.resxfiles whose keys match MudBlazor'sLanguageResource". The doc comment (lines 3-9) records the verbatim-English-mirror invariant. - Why it's built this way: MudBlazor exposes exactly one extension point for translating its built-in strings (an injectable
MudLocalizer), and it needs some resource base to read from. A separate anchor keeps the library's keys in their own table, mirroring the upstream names one to one, cleanly apart from the app's ownSharedResourcechrome. This is the ADR-027 way to translate a dependency you do not own. - Where it's used: injected as
IStringLocalizer<MudTranslations>byResxMudLocalizer, whichAddUISharedregisters as MudBlazor'sMudLocalizerviaTryAddTransient(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:101). Because resolution flows through the DIIStringLocalizerFactory, thePseudoStringLocalizerFactorydecorator registered atDependencyInjection.cs:95reaches these strings too. - Caveats / not-in-source: the
.resxfiles and their per-key match to MudBlazor v9.6.0'sLanguageResourceare resources, not.cs; individual key contents are not enumerated here.
SharedResource
MMCA.Common.UI ·
MMCA.Common.UI.Resources·MMCA.Common/Source/Presentation/MMCA.Common.UI/Resources/SharedResource.cs:9· Level 0 · class (sealed)
- What it is: an empty marker class that anchors
IStringLocalizer<SharedResource>over its co-located.resxfiles, the single home for cross-cutting UI chrome strings (ADR-027). - Depends on: nothing first-party. The type is empty:
public sealed class SharedResource;(line 9). Its meaning comes from the co-located resourcesSharedResource.resx(the English default) andSharedResource.es.resx(Spanish), named in the doc comment (line 7), and from the ASP.NET Core localization stack that bindsIStringLocalizer<T>to the.resxnamed afterT. - Concept introduced, the resource-anchor type.
[Rubric §27, Internationalization]assesses whether user-facing copy is externalized to per-culture resources keyed stably rather than hard-coded. ASP.NET Core'sIStringLocalizer<T>convention resolves keys against the resource file whose base name matches the typeT, so a dedicated empty class becomes the name that ties many components to one shared string table: injectingIStringLocalizer<SharedResource>anywhere reads the same dotted, stable keys (Common.Error.Load,Grid.Snackbar.LoadCancelled,Auth.Sessions.Title). The doc comment (lines 3-8) enumerates the chrome it covers: buttons, layout labels, snackbar and error templates, and the culture- and theme-switcher text. Its counterpart for library chrome isMudTranslations. - Walkthrough: there are no members. The whole contract is "be a public sealed type named
SharedResourcein this namespace, with sibling.resxfiles". The work lives in the key/value pairs and in the localization middleware that resolves them by culture. - Why it's built this way: a marker type is the idiomatic ASP.NET Core way to scope a shared resource table without inventing a real class, and one anchor keeps the chrome strings in a single table every component shares (ADR-027).
- Where it's used: injected as
IStringLocalizer<SharedResource>byDataGridListPageBase<TDto>for its cancellation toast and itsResulterror rendering (DataGridListPageBase.cs:25), bySessionsfor every label on the devices page (Sessions.razor.cs:33), by the auth pages for their field labels and messages, and handed toErrorMessages.Configurefrom the root layout (MMCA.Common/Source/Presentation/MMCA.Common.UI/Layout/MainLayout.razor:103) so the static helper resolves the same table. - Caveats / not-in-source: the
.resxfiles are resources, not.cs; their per-key contents are not enumerated here.
AuthFieldMessages
MMCA.Common.UI ·
MMCA.Common.UI.Resources·MMCA.Common/Source/Presentation/MMCA.Common.UI/Resources/AuthFieldMessages.cs:19· Level 1 · class (internal static)
- What it is: a static lookup of the localized validation messages the auth page models attach to their form fields (first name, last name, email, password, confirm password, token), resolved from the shared resource table.
- Depends on:
SharedResourceonly, as the type that names the.resxtable; it builds aResourceManagerovertypeof(SharedResource).FullNameandtypeof(SharedResource).Assembly(line 21). - Concept: the validation-attribute counterpart to injecting
IStringLocalizer<SharedResource>. A DataAnnotations attribute on a model property needs its message at attribute-evaluation time, where there is no DI container to inject a localizer from, so the messages are static properties read through aResourceManageron demand. Each property is a one-lineGet("Auth.Field.<Field>.<Rule>")call (lines 24-42):FirstNameRequired,LastNameRequired,EmailRequired,EmailInvalid,PasswordRequired,PasswordMaxLength,PasswordComplexity,ConfirmPasswordRequired,ConfirmPasswordMismatchandTokenRequired.[Rubric §27, Internationalization]assesses that user-facing copy is externalized to per-culture resources. - Walkthrough:
Get(line 44) callsResources.GetString(key, CultureInfo.CurrentUICulture)and falls back to the key itself when the entry is missing, so a missing translation shows a stable dotted key rather than throwing or rendering blank. Because it readsCurrentUICultureon every access, the message follows the active culture at the moment the validator runs. - Why it's built this way: keeping the auth messages in the same
SharedResourcetable as the rest of the chrome means one.resxpair per locale (ADR-027) rather than a second resource file just for validation text. The type isinternal, so it is an implementation detail of this assembly, not framework surface. - Where it's used: the auth page models
RegisterModel.cs(10 references),ResetPasswordModel.cs(9),LoginModel.cs(4) andForgotPasswordModel.cs(3), all underMMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/. - Caveats / not-in-source: the
Auth.Field.*keys live in theSharedResource.resxfiles, which are resources rather than.cs, so their per-key text is not enumerated here. How the model attributes bind to these properties (for example throughErrorMessageResourceType) is in the model files, not in this type.
DetailPageBase
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DetailPageBase.cs:26· Level 1 · class (abstract)
- What it is: the shared framework base for a single-aggregate detail page (loaded by route id, edited inline). It owns the two concerns every such page used to repeat: a page-scoped
CancellationTokenSourcewith its dispose pattern, and the inline edit-mode lifecycle (IsEditing/IsDirtyplus the enter and leave transitions the unsaved-changes guard reads). - Depends on:
Microsoft.AspNetCore.Components.ComponentBaseandIDisposableonly, plus the siblingLatestLoadGuardit exposes throughLoadGuard(:43). - Concept: the mirror image of the list-page base one level up,
DataGridListPageBase<TDto>: the same page-scoped cancellation and dirty-tracking plumbing, but for the one-aggregate-by-id shape instead of the paged-grid shape.PageToken(:36) is handed to every awaited call the page makes, so navigating away, or anInteractiveAutorender-mode transition swapping the component out, cancels the in-flight work instead of completing into a component that is gone.LoadGuard(:43, aLatestLoadGuard) is the companion for the route-driven load itself:Begin()at the start of each load cancels the load it supersedes, andIsCurrent(generation)after each await tells a stale response to leave the page state alone. - Walkthrough:
- State (
:28-29): areadonly CancellationTokenSource _ctscreated at construction, and a_disposedidempotency flag. PageToken(:36):protected, exposing only the token, never the source (it returns_cts.LifetimeToken(), not the raw_cts.Token; the extension atMMCA.Common/Source/Presentation/MMCA.Common.UI/Common/ComponentLifetimeExtensions.cs:26hands back an already-cancelled token once the source is cancelled, disposed or null, so work started late in a disposed page stops through its normalOperationCanceledExceptionpath instead of throwingObjectDisposedException), so a derived page can pass it but cannot cancel or dispose it out from under the base.LoadGuard(:43): aprotectedLatestLoadGuardproperty, disposed with the page.IsEditing/IsDirty(:46,:49): bothprotectedwithprivate set, so only the transition methods below can move them.Dispose()(:52-56): the public entry point, delegating toDispose(bool)and callingGC.SuppressFinalize(this).MarkDirty()(:59): the one-line setter bound to every editable field's@bind-Value:after.ClearDirty()(:65): clears the dirty flag without opening or closing the editor, for a child panel that reports its own dirty state in both directions while the page's editor state stays as it is.BeginEdit()(:68-72) andEndEdit()(:75-79): open the editor on a clean slate, and close it clearing the dirty flag, so the dirty flag can never be left set behind a closed editor.Dispose(bool)(:86-101): the standard disposable pattern, guarded on_disposed, disposingLoadGuardand cancelling then disposing_cts.
- State (
- Why it's built this way: the guard component consumes
IsDirtydirectly to drive the "are you sure" prompt, so that prompt is driven by one flag with a small, fixed set of writers rather than by per-page bookkeeping; adding a new detail page costs an@inheritsline and two calls, not a re-implementation of the token, the load guard, and the flag. - Where it's used:
MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Common/DetailPageBaseTests.csand, in current source, ADC'sEventDetail(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Events/EventDetail.razor.cs). - Caveats / not-in-source: this file's own class-level remarks are outside the cited span (they sit above line 26), so the rationale above is drawn from the members' own doc comments rather than a class remark. The usage-site scan surfaces only
EventDetail.razor.cs; whether the other Conference detail pages still inherit this base through an@inheritsdirective in their.razormarkup is not verifiable from a.cs-scoped scan.
OfflineFirstPageSnapshot<TItem>
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/OfflineFirstPageSnapshot.cs:29· Level 1 · class (sealed, generic)
- What it is: a small helper that keeps the last successful first page of a list on the device and hands it back when a fetch fails while the device is offline, so a dead venue network still shows content instead of an empty grid (ADR-042).
- Depends on:
ILocalCacheStoreandIConnectivityStatusService, both taken through the primary constructor along with astring cacheKeyand an optionalstring? userScope = null(lines 29-33), plus the private nestedCachedPagepayload. No external NuGet dependency at all. - Concept introduced, offline-first read-through with a deliberately tiny blast radius.
[Rubric §29, Resilience & Business Continuity]assesses whether a surface degrades gracefully when a dependency is unreachable;[Rubric §19, State Management & Data Flow]assesses where client-side state lives and who owns it;[Rubric §22, Responsive & Cross-Browser]applies because the behavior is head-dependent by design. The teaching point is how narrowly the fallback is scoped. Three conditions must all hold before a cached row is ever shown (CanServe, line 43): the device reports itself offline, the store is available on this head, and the grid asked for page 1. That means the live path is untouched: an online user never reads the cache, a paged-past-page-1 user never reads it, and a head with no local store (Blazor Server, where SSR always has the live API) never reads it becauseILocalCacheStorereports itself unavailable there. The class comment states exactly that contract. - Walkthrough: three public members over a primary constructor, plus a computed field.
- The
_scopedKeyfield (lines 35-37):string.IsNullOrWhiteSpace(userScope) ? cacheKey : $"{cacheKey}.u{userScope}", computed once at construction and used by every store call in place of the barecacheKey. CanServe(int page)(line 43): the single predicate,!connectivity.IsOnline && store.IsAvailable && page == 1. It is public so a caller can also use it as an exception filter, which is how the ADC consumer avoids swallowing a throw it has nothing to answer with.RememberAsync((IReadOnlyList<TItem> Items, int TotalItems) fetched, int page, CancellationToken)(lines 49-59): writes only whenpage == 1 && store.IsAvailable(line 54), materializing aCachedPageand handing it tostore.SetAsync(_scopedKey, ..., cancellationToken)(lines 56-57). Any other page is silently left alone, so a user who paged deep does not overwrite the snapshot of page 1 with page 7.TryReadAsync(int page, CancellationToken)(lines 67-78): returnsnullimmediately unlessCanServe(page)(lines 71-74), then readsstore.GetAsync<CachedPage>(_scopedKey, ...)(line 76) and projects it back into the same tuple shape the fetch delegate returns (line 77), so the caller substitutes it without reshaping anything.
- The
- Why it's built this way: it is a plain class constructed by the consuming service rather than a DI-registered singleton, because the
cacheKeyis per surface and cannot be resolved from the container. The doc comment on that parameter states the invariant plainly: the key must be unique per list surface (and per scope, when one head shows the same list for different tenants or events), since a shared key would let one page serve another page's rows. TheuserScopeparameter folds the signed-in subject id into the stored key: a device is shared, so a snapshot written for one account must not be readable by the next one. Sign-out already wipes the store (ILocalCacheStore.ClearAsync), and_scopedKeyis the defense in depth for the paths that never reach sign-out, an app killed mid-session or a token that simply expired. Returningnullrather than an empty page keeps "nothing cached" distinguishable from "cached and genuinely empty", which is what lets the caller fall through to the real failure. - Where it's used: composed by ADC's
PublicSessionScheduleService, which builds one instance with a per-surface constant key (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Public/PublicSessionScheduleService.cs:28-34) and wires all three members into one fetch:RememberAsyncon every success (:42), a snapshot read when the live query returns a failedResult(:49-50), andCanServeas the exception filter on the guardedcatch(:52-59) so a throw from the store itself is rethrown when there is nothing cached to answer with. Behavior is pinned byMMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Common/OfflineFirstPageSnapshotTests.cs:15, including the per-key isolation case (:97-98). - Caveats / not-in-source: the snapshot has no expiry, no size cap, and no versioning; how long a stale first page can be served is a property of
ILocalCacheStoreand of the head's storage, not of this file. The class is best-effort by design: a store write failure insideRememberAsyncis not caught here.
ListPageQueryStateService
MMCA.Common.UI ·
MMCA.Common.UI.Services·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ListPageQueryStateService.cs:28· Level 1 · class (sealed)
- What it is: the two-way translator between a ListPageState and the browser address bar, so browser back/forward, a refresh and a link pasted into chat all restore the same filtered, sorted, paged view.
- Depends on: ListPageState; externals
NavigationManager,Microsoft.AspNetCore.WebUtilities.QueryHelpersandStringValues. - Concept introduced, the URL as shareable state.
[Rubric §25, Navigation & Information Architecture]assesses whether the address bar reflects what the user is looking at; this class is that contract for every list page. The remarks (ListPageQueryStateService.cs:14-27) document the reserved keys and why they are terse (they end up in shareable links):p(0-indexed desktop page),ps(page size),mp(1-indexed mobile page),s(sort column),sd(desconly, since ascending is the default),d(1only, since comfortable density is the default),q(free-text search) andf:<name>for any other named filter. Defaults are omitted entirely, so a pristine list page has a clean URL.[Rubric §19, State Management & Data Flow]applies because this is the second of the three transports the same record travels over. - Walkthrough
- The key names are private constants (
ListPageQueryStateService.cs:30-40), including the"search"filter name that maps toqby convention and thedesc/1markers. A separateMaxPageSizeconstant of1000(ListPageQueryStateService.cs:45) caps the page size a URL may ask for; its summary (ListPageQueryStateService.cs:42-44) ties it to the same ceilingKeysetPageRequest.MaxPageSizestates. ReadCurrent()(ListPageQueryStateService.cs:50-54) is the instance entry point: it resolves the absolute URI from the injectedNavigationManagerand hands the query to the parser.ParseQueryString(ListPageQueryStateService.cs:61) is deliberatelystaticand public, documented as a pure helper exposed for unit testing without aNavigationManager(ListPageQueryStateService.cs:56-60). It reads the three integers throughTryGetInt(ListPageQueryStateService.cs:222-232, which parses withCultureInfo.InvariantCultureand falls back to the supplied default rather than throwing). Out-of-range values get the same fallback as unparsable ones (ListPageQueryStateService.cs:65-69): the page is floored at0, the mobile page at1, and a page size outside1..1000collapses to0, the page default, throughClampPageSize(ListPageQueryStateService.cs:220). The inline comment states the reason: a hand-edited or stale link withp=-3orps=5000must not leave the list stuck on a failing fetch. It then treats a blank sort value as no sort (ListPageQueryStateService.cs:72-79), matchesdesccase-insensitively (ListPageQueryStateService.cs:84) but the dense marker1ordinally (ListPageQueryStateService.cs:90), then walks every remaining key, foldingqinto thesearchfilter and stripping thef:prefix off the rest (ListPageQueryStateService.cs:94-110).BuildPath(ListPageQueryStateService.cs:129) is the inverse, and the omission rules are visible one by one: page only when> 0(ListPageQueryStateService.cs:136), page size only when> 0(ListPageQueryStateService.cs:141), mobile page only when> 1(ListPageQueryStateService.cs:146),sdonly when a sort column exists and is descending (ListPageQueryStateService.cs:151-158),donly when dense (ListPageQueryStateService.cs:160-163); with no parameters at all it returns the bare base path (ListPageQueryStateService.cs:182-184).ReplaceState(ListPageQueryStateService.cs:203) writes the URL back usingNavigationOptions { ReplaceHistoryEntry = true }(ListPageQueryStateService.cs:216) so filter changes do not pollute the back stack.
- The key names are private constants (
- Why it's built this way: the most instructive part is the guard in
ReplaceState(ListPageQueryStateService.cs:208-213), which drops the write when the current path no longer matches the owningbasePath. The remarks (ListPageQueryStateService.cs:193-202) record the diagnosed defect: a grid-state write is inherently deferred (a debounced search, a lateServerDatacompletion), so it can land after the user has already navigated away. Building from the then-current URI used to stamp grid parameters onto the next page's URL and issue a spurious navigation that disposed it mid-load, and detail pages reached by clicking a list row had their first data fetch canceled about 66ms in, leaving them stuck on their loading state. - Where it's used: registered
TryAddScoped(DependencyInjection.cs:176) and injected into DataGridListPageBase<TDto> (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DataGridListPageBase.cs:28), which reads the URL on initialization and on parameter changes (DataGridListPageBase.cs:217andDataGridListPageBase.cs:278) and writes it back after a grid or filter change (DataGridListPageBase.cs:981andDataGridListPageBase.cs:1023).
ListPageStateService
MMCA.Common.UI ·
MMCA.Common.UI.Services·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ListPageStateService.cs:63· Level 1 · class (sealed)
- What it is: the per-circuit memory of list-page state, keyed by route, with an optional
write-through to
sessionStorageso the state survives things a circuit-scoped dictionary cannot. - Depends on: ListPageState and LazyJsModule; externals
IJSRuntime,IJSObjectReferenceand thenav-interop.jsmodule shipped in the package'swwwroot. - Concept introduced, a synchronous fast path with an asynchronous durable path.
[Rubric §19, State Management & Data Flow]assesses how state survives lifecycle boundaries; this class answers with two tiers. The class comment (ListPageStateService.cs:55-62) names exactly what the durable tier buys: state survives circuit teardowns,forceLoad: truenavigations and the SSR to WASM render-mode transition. The synchronous dictionary matters just as much, because it is safe to read fromOnInitializedduring prerender, when JS interop does not exist yet. - Walkthrough
- Two constants set the contract: the module path
./_content/MMCA.Common.UI/nav-interop.js(ListPageStateService.cs:65) and themmca.lps:session-key prefix (ListPageStateService.cs:66). State is a plainDictionary<string, ListPageState>(ListPageStateService.cs:68) plus one LazyJsModule (ListPageStateService.cs:69). GetState(ListPageStateService.cs:76-77) is aGetValueOrDefaultand is documented as safe to call during SSR prerender (ListPageStateService.cs:71-75).SaveState(ListPageStateService.cs:84-85) stores in memory only.UpdateScrollPosition(ListPageStateService.cs:92-95) is the fast path for scroll events: it uses awithexpression to preserve every other field, and creates a minimal entry when none exists yet, for the case where the user scrolls before the grid has fired its first save.HydrateFromSessionAsync(ListPageStateService.cs:103) invokessessionGeton the JS module (ListPageStateService.cs:113, the export atMMCA.Common/Source/Presentation/MMCA.Common.UI/wwwroot/nav-interop.js:12) and adopts any persisted snapshot (ListPageStateService.cs:114-117).PersistToSessionAsync(ListPageStateService.cs:138) does the reverse throughsessionSet(ListPageStateService.cs:153,nav-interop.js:24), returning early when there is nothing in memory to write (ListPageStateService.cs:140-143).- Both wrap the interop in the same three-catch shape:
InvalidOperationExceptionfor prerender,JSDisconnectedExceptionfor a torn-down circuit andJSExceptionas the defensive catch for storage failures such as Safari Private mode or an exceeded quota (ListPageStateService.cs:119-130andListPageStateService.cs:155-166). - The private
GetModuleAsync(ListPageStateService.cs:169-183) converts an unavailable runtime into anullmodule rather than an exception, which is what makes the two public methods' early returns read cleanly.DisposeAsync(ListPageStateService.cs:186) simply forwards to the module wrapper.
- Two constants set the contract: the module path
- Why it's built this way: the degradation contract here is "never let storage failures break the calling page", which is why this class swallows what LazyJsModule deliberately does not. Scoped registration means one instance per circuit, so the in-memory dictionary is naturally per-user without any keying by identity.
- Where it's used: registered
TryAddScoped(DependencyInjection.cs:175) and injected into DataGridListPageBase<TDto> (DataGridListPageBase.cs:27), which reads it during state restore (DataGridListPageBase.cs:219), hydrates from session on first render (DataGridListPageBase.cs:347-352), records scroll offsets (DataGridListPageBase.cs:411), and saves plus persists after grid and density changes (DataGridListPageBase.cs:962-987andDataGridListPageBase.cs:1018-1027).
MudAppDialogService
MMCA.Common.UI ·
MMCA.Common.UI.Services·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/MudAppDialogService.cs:11· Level 1 · class (internal sealed)
- What it is: the MudBlazor-backed implementation of the framework's confirm-prompt facade. It asks
MudBlazor's message box for a yes/no answer and reduces it to a single
bool. - Depends on: IAppDialogService (implemented) and MudBlazor's
IDialogService(MudAppDialogService.cs:1-2). It has no other state. - Concept introduced, quarantining the component library behind a facade.
[Rubric §15, Best Practices & Code Quality]assesses how much of the codebase would have to change if a vendor dependency changed; the class comment (MudAppDialogService.cs:6-9) records the answer for dialogs: this type and MudToastService are the only two types in the framework that name a component-library service.[Rubric §14, Testability]is the other half of the payoff, spelled out on the interface (MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Interfaces/IAppDialogService.cs:8-12): a test can answer the prompt with a stub instead of driving a rendered dialog. - Walkthrough
- The primary constructor takes MudBlazor's
IDialogService(MudAppDialogService.cs:11); the class isinternal, so consumers only ever see the interface. ConfirmAsync(string title, string message, string confirmText, string cancelText)(MudAppDialogService.cs:14) forwards toShowMessageBoxAsyncwith the confirm label asyesTextand the decline label ascancelText(MudAppDialogService.cs:19-23). The labels are already localized by the caller, per the interface doc (IAppDialogService.cs:21-24).- The return is
confirmed is true(MudAppDialogService.cs:25). The comment above it (MudAppDialogService.cs:16-18) states the contract:ShowMessageBoxAsyncanswersnullwhen the user dismissed the dialog without choosing (backdrop click, escape), and collapsing that ontofalsemeans only an active confirmation counts as one, so callers never have to branch on three outcomes.
- The primary constructor takes MudBlazor's
- Why it's built this way: the interface deliberately exposes only the one shape the framework needs
(a yes/no question before something irreversible or lossy), leaving richer entity-specific dialogs
component-side (
IAppDialogService.cs:3-7). Keeping the implementationinternaland registered byAddUISharedmeans an app cannot accidentally depend on the MudBlazor type through this path. - Where it's used: registered with
TryAddScoped<IAppDialogService, MudAppDialogService>()(DependencyInjection.cs:231, under the facade-registration doc atDependencyInjection.cs:213-227). Consumers resolve the interface: the sharedUnsavedChangesGuardcomponent (MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/Forms/UnsavedChangesGuard.razor:14andUnsavedChangesGuard.razor:57) and the Helpdesk seed's ticket pages (MMCA.Helpdesk/Source/Hosts/UI/MMCA.Helpdesk.UI.Web/Components/Pages/Tickets.razor:104andComponents/Pages/TicketDetail.razor:348). A bUnit test pins the registration to this implementation (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Infrastructure/BunitComponentTestBaseFacadeTests.cs:32).
MudToastService
MMCA.Common.UI ·
MMCA.Common.UI.Services·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/MudToastService.cs:19· Level 2 · class (internal sealed)
- What it is: the MudBlazor-backed IToastService. It is one of only two types in the framework that name a component-library service, the other being its sibling MudAppDialogService.
- Depends on: IToastService (the contract implemented at
MudToastService.cs:19) and ToastSeverity (the vendor-neutral level, taken as aShowparameter at line 38 and switched over at lines 96-102); MudBlazor'sISnackbar(held in a private field, line 21),Severity,Variant,ColorandSnackbarOptions, andMicrosoft.AspNetCore.Components.Rendering.RenderTreeBuilder(ASP.NET Core) for the one method that renders markup (lines 45-50). - Concept introduced, the vendor boundary.
[Rubric §20, Design System, Theming & UI Consistency]assesses whether the app depends on its own design vocabulary rather than on a specific component library's API. Every page, component andResulthelper in both applications depends onIToastService; only this class andMudAppDialogServiceknow that MudBlazor exists, and the DI comment says so outright (MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:185-188).[Rubric §14, Testability & Test Strategy]is the practical payoff: a test records toasts against the interface without rendering a snackbar host, which is how ResultUiExtensions.NotifyOnFailurecan be tested at all (MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/ResultUiExtensions.cs:289,301).[Rubric §1, SOLID Principles]covers the shape: an internal implementation behind a public interface means no consumer can name the concrete type even by accident. The class doc comment also states why toast text is not duplicated into a second screen-reader channel:MmcaThemeProvidershosts MudBlazor'sMudSnackbarProviderinside arole="status" aria-live="polite"element, so the rendered toast is itself the live-region content, and pushing the same sentence through a second channel would announce it twice and make every text locator ambiguous. - Walkthrough
- The class holds
ISnackbarin a private field (_snackbar, line 21) set by an explicit constructor (line 23) rather than a primary-constructor parameter; the two are behaviorally identical, only the field-access syntax changed. - Four one-liners cover the common levels:
Success,Info,WarningandError(lines 26, 29, 32, 35), each a direct_snackbar.Add(message, Severity.X).Show(message, severity)(line 38) is the same call with the level chosen at runtime. ShowPersistent(title, body, severity)(line 41) is the push-notification shape. It renders a two-line body through aRenderTreeBuilder(a bolded title, a line break, then the body, lines 45-50) and setsRequireInteraction = truewithVariant.Filled(lines 57-58). The comment states the rule (lines 55-56): the message arrived unprompted, so it must survive until the user has actually looked at the screen rather than expiring on the default timer.ShowAction(message, actionText, onAction, severity, requireInteraction)(line 62) is the undo-style toast. It setsActionandActionColor(lines 73-74) and adapts MudBlazor's click signature to the caller's parameterless delegate by discarding theSnackbarinstance MudBlazor passes (line 78).requireInteractionis opt-in: when false the options are left untouched so the host's own snackbar timing applies, and when true bothRequireInteractionandVariant.Filledare stated outright rather than relying on MudBlazor's null default (comment at lines 82-85, values at lines 86-87).Map(ToastSeverity)(line 96) projects the neutral enum onto MudBlazor's with an explicit switch over all five members plus aNormaldefault (lines 98-103). It is written out rather than cast on purpose: the two enums agree numerically today, and an implicit dependency on that would break silently the day either side gains a member (comment at lines 91-94).- Nothing wraps
onAction. The absence is a documented contract, pinned by a test that asserts a throwing callback propagates (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/MudToastServiceTests.cs:49-59): a caller whose work can fail guards it instead of discovering the failure as a swallowed no-op.
- The class holds
- Why it's built this way: keeping the vendor type behind a facade is what makes the component
library swappable in principle and mockable in practice, and it is the reason the framework ships
AddCommonUiFacades()as its own registration (MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:228-233), factored out so a bUnit harness can register exactly these two services without pulling in the whole shared-UI surface (comment at lines 144-157). Every method returnsvoid: a toast is fire-and-forget by design, and MudBlazor'sISnackbar.Addis synchronous. - Where it's used: registered by
AddCommonUiFacadeswithTryAddScoped(DependencyInjection.cs:230), whichAddUISharedcalls for every host (DependencyInjection.cs:159). Consumers resolveIToastService, never this type: the framework'sNotificationListenerraises an incoming push as a persistent toast (MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/Notifications/NotificationListener.razor:49), ResultUiExtensions.NotifyOnFailureturns a failed Result into one (MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/ResultUiExtensions.cs:301), and ADC's LiveEventListener uses the action shape for its reconnect prompt (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Components/LiveEventListener.razor.cs:81,166). Its own behavior is pinned by MudToastServiceTests, which captures the options lambda and applies it to a freshSnackbarOptionscarrying MudBlazor's defaults, so the assertions see exactly what a rendered snackbar would (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/MudToastServiceTests.cs:163). - Caveats:
ShowPersistentrenders the title and body as content in a render fragment, so both are escaped by the renderer, but neither string is length-bounded in source: a long push body produces a correspondingly tall toast.Show,Successand the rest pass the caller's string straight to MudBlazor, so any localization has to happen before the call; the facade does not touchIStringLocalizer.
DataGridListPageBase<TDto>
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DataGridListPageBase.cs:22· Level 3 · class (abstract)
- What it is: the abstract Blazor base for every server-paged
MudDataGrid<TDto>list page. It folds the otherwise copy-pasted concerns (cancellation lifecycle, loading and failure flags, mobile/desktop viewport detection, filter and sort extraction, error reporting, scroll tracking and restore, density toggle, URL plus session plus prerender state plumbing, an opt-in virtualization funnel, newest-load-wins ordering for overlapping paged loads, and disposal) into one reusable component:class DataGridListPageBase<TDto> : ComponentBase, IBrowserViewportObserver, IAsyncDisposable, IDisposable(line 22). - Depends on:
IToastService,SharedResource(asIStringLocalizer<SharedResource>),ListPageState,ListPageStateService,ListPageQueryStateService,BreakpointConstants,ErrorMessages,ResultUiExtensions(NotifyOnFailure),Result, and the nestedPersistedGridState. Externals: MudBlazor'sMudDataGrid<T>,GridState<T>,GridStateVirtualize<T>,GridData<T>,IBrowserViewportObserver/IBrowserViewportService, and Blazor'sPersistentComponentState,NavigationManager,IJSRuntime,RendererInfo. - Concept introduced, a behavior-rich Blazor base component.
[Rubric §18, UI Architecture & Component Design]assesses reuse, and every list page in both apps inherits this behavior with no copy-paste.[Rubric §23, Front-End Performance & Rendering]assesses render and fetch cost: only the requested page is ever fetched, the prerender cache skips a redundant round trip, and the opt-in virtualization funnel keeps the DOM small for large sets.[Rubric §19, State Management & Data Flow]covers the four-channel persistence (URL, in-memory, sessionStorage, prerender cache) and the newest-load-wins rule for overlapping fetches.[Rubric §27, Internationalization]applies because the cancellation toast and everyResultfailure message resolve throughSharedResource.[Rubric §24, Forms, Validation & UX Safety]shows up in theLoadFailedflag (line 53): a failed fetch renders zero rows, which is visually identical to a genuinely empty list once the error toast expires, so derived pages branch on the flag to show an inline error-with-retry instead of the "no records" empty state. Several hard-won defect fixes live here too, each with the diagnosis inline: the MudDataGrid v9RowsPerPagesetter that always resetsCurrentPage(RestoreCurrentPageAfterRowsPerPageReset, line 422), the disposed-CTS race that stuck theblazor-error-uibanner (ResetCancellationTokenAsync, line 877), the stale-write race where a late grid-state save stamped grid parameters onto the next page's URL (_ownRoutePath, lines 215 and 1032), and the last-completing-call race in MudDataGrid'sServerData(NewestPagedResultAsync, line 571), all of which were E2E-discovered, touching[Rubric §28, Front-End Testing]. - Walkthrough, in teaching order:
- Injected services and abstract surface (lines 24-31):
IToastService(line 24, the onlyprotectedone, so derived pages toast through the same abstraction),IStringLocalizer<SharedResource>(line 25),IBrowserViewportService(line 26), the two state services,NavigationManager,IJSRuntime,PersistentComponentState(line 31). Derived pages supply the abstractTitle(line 54) and may overrideSaveFilters/RestoreFilters(lines 128, 131),GridRef(line 141),OnMobileDataRequestedAsync(line 1047), and the three virtualization knobs. - Public and protected state:
IsLoading(lines 41-45),LoadFailed(line 53),IsMobile(line 57), the mobile card-view blockMobileItems/MobileTotalItems/MobileCurrentPage/MobilePageSize(lines 60-63), the bindableCurrentPageState(line 70, 0-indexed),RowsPerPageState(line 80, defaulting to 10 to match MudDataGrid v9's own default), andDenseGrid(line 89).IsLoadingis a computed property,get => field || !RendererInfo.IsInteractive(line 42): it is always true during the non-interactive SSR prerender pass, because the grid fetches fromOnAfterRenderAsync, which a static render never reaches, so without this the prerendered HTML would carry the "no records" empty state until the interactive render loads the rows. Once interactive it reflects the latest load only (seeRunFetchAsync). - Constants (lines 95, 99):
PrerenderFetchTimeoutMs = 5000bounds the SSR fetch, andVirtualizedScrollContainerSelector = ".mud-table-container"records where a virtualized grid actually scrolls (the grid's own height-bound viewport, not the document). - Private fields (lines 101-118): the CTS, the
_disposedguard, the scroll module and itsDotNetObjectReference, the persistence subscription, the prerender caches_persistedGridData/_lastSuccessfulGridData,_newestPagedLoad(line 109, the newest paged load task),_pendingScrollRestore, the saved-state mirrors_savedPage/_savedPageSize/_savedSortColumn/_savedSortDescending, the re-entrancy and deferral flags, and a per-instance_scrollTrackerIdGUID. The observer contract'sIdandResizeOptions(a 250 ms report rate) sit at lines 122 and 125;_ownRoutePath, the stale-write anchor, is declared later at line 1032. - The virtualization opt-in (lines 154, 162, 170):
VirtualizeGriddefaults tofalse, so every existing page keeps its pager untouched. A page that overrides it totruebindsVirtualize,Height="@VirtualizedGridHeight"(default70vh),ItemSize="VirtualizedItemSize"(default 52, the comfortable-density row height) andVirtualizeServerDatainstead ofServerData: the doc comment records that MudBlazor v9 accepts only one of the two funnels and that binding both leaves the grid fetching through a pager it no longer renders. Turning it on also disables the pager-restore machinery, which has no meaning without a pager; sort, filter, and density persistence still apply. OnInitialized(from line 179), synchronously: (a) restores anyPersistedGridStateunder the keygrid:{GetType().FullName}; (b) registers the persisting callback with an explicitRenderMode.InteractiveAuto(line 198); (c) pins_ownRoutePathto this page's route (line 215); (d) reads the URL throughListPageQueryStateServiceand falls back to the in-memoryListPageStateServicesnapshot when the URL carries no state; (e) primesCurrentPageState,RowsPerPageState,MobileCurrentPage, sort, andDenseGrid, then callsRestoreFiltersso the grid's firstServerDatacall already fetches the right page; (f) sets_deferSessionPersistwhen neither channel had state and picks up a pending scroll position; and (g) subscribes toLocationChanged.OnLocationChanged(from line 262): honors the one-shot_suppressNextLocationChangedflag (line 266), reacts only to same-path back/forward navigation (a different path returns early and is handled by disposal, line 273), re-reads the URL into the mirror fields, then re-appliesCurrentPageto the live grid through the BL0005-suppressedApplyCurrentPageFromUrl(line 309) and reloads. The virtualized path skips the page re-apply entirely, because there is no pager to move.NotifyBrowserViewportChangeAsync(from line 318): theIBrowserViewportObservercallback, recomputingIsMobilefromBreakpointConstants.IsMobileBreakpoint(line 322) and, on a desktop-to-mobile transition only, resetting to page 1 and requesting mobile data.OnAfterRenderAsync(firstRender)(from line 339): on first render it hydrates session state now that interop is available (HydrateFromSessionAsync, line 347), runs the cross-circuit fallback (needsSessionRestore, line 353), clears the deferral, subscribes to viewport changes, imports./_content/MMCA.Common.UI/list-page-scroll.jsand enables debounced (150 ms) scroll tracking through aDotNetObjectReferencescoped toScrollContainerSelector(enableScrollTracking, line 374; selector at line 403), then callsRestoreGridStateAsync(line 380) and forces a sessionStorage sync. On every render it restores a pending scroll position once the grid has stopped loading. JS calls back into[JSInvokable] OnScrollPositionChanged(line 409), which updates only the scroll field so page, page size, and filters are untouched.RestoreGridStateAsync(from line 456) is the single entry point for the pager-restore machinery, so virtualization opts out in one place (still honoring a session-driven reload). Otherwise it forcesSetRowsPerPageAsync(_savedPageSize, resetPage: false)when the parameter did not take (line 481), then callsRestoreCurrentPageAfterRowsPerPageReset(line 422) because the v9 setter clobbersCurrentPageto 0, and finally reloads when session hydration changed pagination after the grid's first fetch.LoadServerDataAsync(state, fetchAsync, additionalFilters, showCancelSnackbar)(lines 521-532), the paged entry point, is now a thin non-async wrapper. It startsLoadPagedAsync(line 529), records the returned task in_newestPagedLoad(line 530) before anything yields (the load's own cancellation reset cancels the previous load, whose continuation must already see this one as its successor), and returnsNewestPagedResultAsync(load)(line 531). Overlapping calls therefore end on the NEWEST call's result: a superseded call returns the later call's rows and total, never its own empty cancelled page.LoadPagedAsync(from line 535) is one paged load: it resets the CTS (line 541) and returns the prerender cache on the first interactive call, now also settingIsLoading = false(line 553) because that call is the latest load and is already finished, still saving state (line 556); otherwise it delegates toFetchPagedAsync(line 560).NewestPagedResultAsync(load)(lines 571-581): awaits its own load, then loops while_newestPagedLoadis a different task, adopting and awaiting that newer load, and returns the newest data. It exists because MudDataGrid applies whicheverServerDatacall completes LAST, not the newest, so a superseded load (token cancelled by the newer call's reset) would otherwise overwrite the newer rows with its empty cancelled page.FetchPagedAsync(from line 588) holds the actual fetch, routed throughRunFetchAsync: inside the fetch body it extracts filters and sort, calls the delegate with a 1-based page number, and on success caches_lastSuccessfulGridDataand callsSaveCurrentState; a failedResultgoes throughFailedFetch(fetched, EmptyGridData). No extra token is linked in (CancellationToken.None): the paged funnel has no per-request token of its own, and the SSR pre-render timeout still applies becauseRunFetchAsyncbuilds the token throughCreateFetchCts.LoadVirtualizedServerDataAsync(state, fetchAsync, additionalFilters, cancellationToken)(lines 642-690), theVirtualizeServerDatacounterpart, resets the CTS (line 651) and runs throughRunFetchAsync(line 654,showCancelSnackbar: falsesince cancellation here is always silent: a virtualized grid supersedes its own in-flight fetch on every scroll burst, so a cancel toast would fire continuously and say nothing actionable). Inside the fetch body it maps the row window MudBlazor asks for onto the same page-based fetch delegate (line 661); when the window straddles two pages it fetches the following page too and concatenates (lines 670-679), then trims to exactly the requested count (line 682). MudBlazor's own per-windowcancellationTokenparameter is forwarded straight intoRunFetchAsync'sadditionalToken(line 689) so a superseded fetch stops at the API boundary.RunFetchAsync<TResult>(fetchAsync, onCancelled, onFailed, showCancelSnackbar, additionalToken)(lines 717-764), the one fetch pipeline behind the paged, virtualized, and mobile loaders. It capturesloadSource = _ctsfirst (line 724), setsIsLoadingand clearsLoadFailed(lines 725-726), builds the fetch token throughCreateFetchCts(additionalToken)and runs the caller'sfetchAsyncentirely inside thetry(lines 729-733), because the caller'sadditionalFilterscallback (and the filter/sort extraction around it) is arbitrary page code and a throw from it used to strandIsLoadingattrue.OperationCanceledExceptionmaps toonCancelled(), but the localizedGrid.Snackbar.LoadCancelledtoast fires only whenshowCancelSnackbar && ReferenceEquals(loadSource, _cts) && !_disposed(line 742): a superseded load lost its source to the newer one (the reset swaps before it cancels) and a disposed component has nobody to tell, so both end silently, while a user cancel throughCancelLoadingcancels the current source without replacing it and still toasts. Any other exception maps toonFailed()plusErrorMessages.LoadError(line 751) andLoadFailed = true. ThefinallyclearsIsLoadingonly whenReferenceEquals(loadSource, _cts)(lines 757-760), so only the LATEST load ends the loading state; otherwise the grid would drop its Cancel button and show the empty state while the newer load is still in flight. Each caller supplies its ownonCancelled/onFailedresult shape (an emptyGridData<TDto>for the two grid loaders,falsefor the mobile loader).FailedFetch<TResult>(fetched, onFailed)(from line 770): theResult-branch counterpart toRunFetchAsync's exception branch, reporting a failed fetch the same way,fetched.NotifyOnFailure(Toast, Localizer),LoadFailed = true, thenonFailed().EmptyGridData()(line 777): aprivate staticone-liner returningnew GridData<TDto> { Items = [], TotalItems = 0 }, passed as theonCancelled/onFaileddelegate to both grid loaders.ComputeVirtualWindow(startIndex, count)(lines 792-801), the pure arithmetic behind the virtualized window mapping and the reason it is testable: the window's own size becomes the page size, so an aligned window is exactly one page and an unaligned one spills into the next (offset > 0). It isinternal staticprecisely so the unit tests can drive it directly.CreateFetchCts(additionalToken)(from line 813): links to the active_cts, plus the caller's token when one can be cancelled, and during non-interactive prerender (!RendererInfo.IsInteractive, line 818) callsCancelAfter(PrerenderFetchTimeoutMs)(line 820) so a cold or unreachable backend cannot block the page load indefinitely.LoadMobileDataAsync(lines 830-868), the mobile-card equivalent, also routed throughRunFetchAsync(line 842). Its failure shape depends on the page:onFailedisstatic () => falsewhenMobileCurrentPage > 1(an infinite-scroll append keeps the items,MobileTotalItemsandMobileCurrentPage, so the inline error and Retry render below what is already loaded and Retry re-requests the same page) andClearMobileItems(line 870) on page 1 (line 841). A failedResultgoes throughFailedFetch(fetched, onFailed)(line 851), and cancellation returnsfalsewith no toast and keeps the cards shown (onCancelled: static () => false, line 864). ItsSaveCurrentState(0, 0, ...)call is deliberate (comment at lines 857-860): persisting the mobile page size would overwrite the desktop grid'sRowsPerPage, so a user who chose 50 rows and then narrowed the viewport would come back to 10.ResetCancellationTokenAsync(from line 877): swaps in a fresh CTS first (line 885) so the caller always has a valid token, then tears down the previous one, toleratingObjectDisposedException.ExtractGridFilters(from line 911) flattens MudDataGrid's filter definitions into a one-entry-per-column dictionary, grouping by property name and letting the newest row win rather than throwing on the duplicate key a second filter on the same column would produce; it takes the definition collection rather than the state object so the paged and virtualized funnels share one implementation.ExtractSortParameters(line 926) takes the first sort definition, andResolveSortParameters(line 938) adds the first-fetch fallback: when MudDataGrid has not yet picked up aSortDefinition, the sort restored from the query string is used, so the data lands sorted from the very first request.SaveCurrentState(from line 952): guarded byIsOwnRouteCurrent()(line 956, the stale-write drop), it composes a newListPageStatepreserving the existing scroll position and writes it to all three channels: the in-memory service, the URL viaReplaceState(line 981) with_suppressNextLocationChangedset first (line 980) so it does not re-trigger its own handler, and sessionStorage, skipped during the deferred-hydration window.ToggleDensity/PersistDensity(lines 996 and 1009): flipsDenseGridand mirrors just that one field through the same three channels using awithexpression on the existing state, under the sameIsOwnRouteCurrentguard (line 1012), so a density change made before the grid's firstServerDatasave is not lost.- Route pinning:
_ownRoutePath(line 1032),GetRoutePath()(line 1034, falling back to the live URI only before initialization), andIsOwnRouteCurrent()(line 1040). CancelLoading(line 1049), the manual cancel hook a page can bind to a stop affordance.DisposeAsync/Dispose(from lines 1052 and 1097): dispose the persistence subscription, unsubscribeLocationChanged(helperUnsubscribeLocationChanged, line 1113), disable scroll tracking and dispose the JS module guarded against shutdown-time races (JSDisconnectedException, line 1070, andJSException), dispose theDotNetObjectReferencein afinally, unsubscribe the viewport observer best-effort, and cancel plus dispose the CTS. Both paths are_disposed-idempotent.
- Injected services and abstract surface (lines 24-31):
- Why it's built this way: every concern here was independently re-implemented (and re-broken) on individual pages before being lifted into one base, so a single fix now propagates to every list page at once. The four-channel persistence covers the full matrix of how a user can leave and return to a list: browser back, in-app navigation, refresh or
forceLoad, and a shared link. The delegate signature deliberately mirrorsIEntityService<TEntityDTO, TIdentifierType>.GetPagedAsyncexactly (remarks at lines 511-520), so a page still passes a method group with no adapter, and the move to aResult-returning delegate means a server failure is handled on the same terms an exception used to be, with the API's own localized wording reaching the toast throughResultUiExtensions. The newest-load-wins wrapper and theloadSourceidentity checks exist for the same reason: MudDataGrid applies the last-completingServerDatacall, so the base, not each page, must make an overlapping or superseded load harmless. - Where it's used: base class for the list pages in both apps, including ADC's
UserList(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/Users/UserList.razor.cs:18) andSessionList(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/SessionList.razor.cs:22), and Store'sOrderList(MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.UI/Pages/Orders/OrderList.razor.cs:21), alongside the Catalog, Identity, and Engagement list pages. The virtualized funnel is exercised by the backend-less gallery pageGridGallery(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Gallery/Pages/GridGallery.razor:44,:50), which the deploy-gating E2E suite uses to assert that far fewer rows render than the data set holds and that scrolling happens inside the grid's own viewport (MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/Layout/GridPageE2ETests.cs:34,:52, with a WCAG 2.1 AA scan at:77). The base's own behavior is covered by bUnit tests atMMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Common/DataGridListPageBaseTests.cs:22. - Caveats / not-in-source: two
BL0005suppressions (nearApplyCurrentPageFromUrl, line 309, andRestoreCurrentPageAfterRowsPerPageReset, line 422) setgrid.CurrentPagefrom outside the component; the justification (MudDataGrid v9 exposes no public method for arbitrary-page navigation and the setter is well behaved) is inlined at both. The prerender optimization assumes a warm backend; under a cold one the prerender fetch times out at 5 s and the interactive pass refills the grid. Thelist-page-scroll.jsmodule (enableScrollTracking/setScrollPosition/disableScrollTracking) is JavaScript underwwwroot, invoked here only by name, so its behavior is not verifiable from this.csfile. Note also that the route comparison isOrdinalinOnLocationChanged(line 273) butOrdinalIgnoreCaseinIsOwnRouteCurrent(line 1041); the source does not state why the two differ.
ListPageActions
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/ListPageActions.cs:15· Level 4 · class (static)
- What it is: two static helpers that every list page shares: reload whichever layout (mobile list or desktop grid) is currently rendered, and run the confirm-delete-toast-reload flow.
- Depends on:
MobileInfiniteScrollList<TItem>,IToastService,Result, and theDeleteConfirmationdialog component (MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/Forms/DeleteConfirmation.razor:27). Externals: MudBlazor'sMudDataGrid<T>. - Concept introduced, the shared page helper that stays out of the base class.
[Rubric §15, Best Practices & Code Quality]assesses whether a repeated flow exists once;[Rubric §24, Forms, Validation & UX Safety]assesses that destructive actions confirm first and that failures surface to the user. The placement argument is in the class comment (lines 8-13) and is the interesting part: these are kept as plain statics rather than members onDataGridListPageBase<TDto>so that a page which composes its own layout, or holds several grids, can reuse them without inheriting anything. Inheritance would have forced every consumer into the base class's whole lifecycle just to get two flows. - Walkthrough: two static methods.
ReloadActiveLayoutAsync<TDto>(bool isMobile, MobileInfiniteScrollList<TDto>? mobileList, MudDataGrid<TDto>? dataGrid)(lines 25-38). When the mobile layout is active and its ref is bound it callsmobileList.ResetAsync()(line 32); otherwise it callsdataGrid.ReloadServerData()when that ref is bound (line 36). Both refs are nullable by design: only one layout is in the render tree at a time, so the other@refis genuinely null, which makes the null checks the mechanism rather than defensive noise ([Rubric §22, Responsive & Cross-Browser]).DeleteWithConfirmationAsync(...)(lines 56-93) takes the page'sDeleteConfirmationref, the entity display name, aFunc<Task<Result>>delete call, the toast service, a localized success message, aFunc<Result, string>error mapper, and a reload callback. It guards every reference argument withArgumentNullException.ThrowIfNull(lines 65-69), shows the dialog, and returns immediately unless the answer is exactlytrue(lines 71-75): a dialog dismissed withnullis a cancel, not a confirm. On confirm it awaits the delete and branches on theResult(lines 79-87): a failure toasts the mapped error and returns without reloading, a success toasts and reloads. The singlecatch (OperationCanceledException)(lines 89-92) is swallowed with a comment naming the two causes, component disposal and the InteractiveAuto render-mode transition where a Server-rendered circuit is torn down as WebAssembly takes over.
- Why it's built this way: passing the localized strings and the error mapper in as parameters keeps this class free of any resource dependency, so each page supplies its own translated text (ADR-027) while the flow itself stays identical everywhere. The
errorMessagedelegate is what lets a page choose between a fixed sentence and the API's own wording viaresult.LocalizedErrorMessage(L), which the parameter doc (lines 50-54) spells out. - Where it's used: sixteen list pages across both apps in current source. ADC calls both methods from Identity's
UserList(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/Users/UserList.razor.cs:57,:77), from Conference'sEventList,SessionList,SpeakerList,RoomList,QuestionList,ConferenceCategoryList,SponsorList,ActivityList,PublicEventList, andPublicSessionListView, and from Engagement'sAttendeeSearchPanel(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/CheckIns/AttendeeSearchPanel.razor.cs:60). Store calls them fromProductList(MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.UI/Pages/Products/ProductList.razor.cs:38,:72,:79),CategoryList,OrderList, andCustomerList. Covered byMMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Common/ListPageActionsTests.cs:20. - Caveats / not-in-source:
DeleteWithConfirmationAsynccatches onlyOperationCanceledException; any other throw from the caller'sdeleteAsyncorreloadAsyncdelegate propagates to the page's own handler, which is not visible from this file.
IOAuthUISettings
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth.OAuth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/OAuth/IOAuthUISettings.cs:9· Level 0 · interface
- What it is: three booleans that tell the shared login page which external identity providers this host can actually use, so a social login button renders only where the provider is really wired up.
- Depends on: nothing first-party. Implemented in the framework by
DefaultOAuthUISettingsandConfigurationOAuthUISettings; consumed by the sharedLoginpage. - Concept introduced, default interface members as a safe-off baseline. All three members carry a
body returning
false(IOAuthUISettings.cs:12,15,18), so an implementation can be an empty class and still compile with every provider hidden. That is what makes the framework's default a seven-line file rather than a stub with three properties.[Rubric §18, UI Architecture & Component Design]assesses whether a component asks a typed contract rather than reaching into configuration. The login page injects this interface and never touchesIConfiguration, so the same markup works on a host that has no OAuth at all.[Rubric §26, Front-End Security]assesses what the client is told. The contract carries availability only: no client id, no secret, no redirect URI. The class docs state the intent directly, that implementations declare availability so the login page can conditionally render social buttons (IOAuthUISettings.cs:3-8).
- Walkthrough: three get-only members,
GoogleEnabled(IOAuthUISettings.cs:12),GitHubEnabled(line 15) andAppleEnabled(line 18), each declared asbool X => false. - Why it's built this way: external login is optional per host, and the decision has to be readable from the render tree. Making the interface the question (rather than a settings object) lets the framework register a no-op default and lets a host swap in a real answer without any page change. The federated login flow the flags gate is recorded in ADR-036, and the mobile callback variant in ADR-043.
- Where it's used:
AddUIShared()registers the no-op default withTryAddSingleton(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:204, with the override instructions in the comment at lines 133-134). The shared login page injects it (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Login.razor:11), guards each provider button on it (lines 86, 107, 128) and folds the three flags into one_hasExternalProvidersvalue that decides whether the whole external-login block renders (line 167). MMCA.ADC registersConfigurationOAuthUISettingson all three heads (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:89,MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web.Client/Program.cs:38,MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/MauiProgram.cs:101).
PendingAttempt
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth.OAuth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/OAuth/OAuthFlowStateStore.cs:106· Level 0 · record (private, sealed)
- What it is: the private record OAuthFlowStateStore persists while an OAuth
redirect is in flight,
State(the random value sent on the challenge URL) andStartedAt(the timestamp used to expire an abandoned attempt). - Depends on: nothing beyond the BCL (
string,DateTimeOffset). - Where it's used: written and read only inside
OAuthFlowStateStore (
OAuthFlowStateStore.cs:96,100); it never crosses the store's own boundary.
ISessionCookieSync
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/ISessionCookieSync.cs:8· Level 0 · interface
- What it is: a two-method contract for mirroring the client's in-memory tokens into the browser's HttpOnly auth cookies, and for clearing them again on logout. Both methods report whether the cookie jar was actually updated.
- Depends on: nothing first-party. Implemented by
JsFetchSessionCookieSyncand, on a Blazor Server host that enabled the same-origin proxy, byHandoffSessionCookieSync(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/HandoffSessionServices.cs:52); consumed byWasmTokenStorageServiceand byServerTokenStorageService(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Services/ServerTokenStorageService.cs:33). - Concept introduced, the prerender visibility gap. A Blazor Web App renders a server-side pass
before the interactive circuit exists. During that pass there is no
Authorizationheader and no way to read the interactive client's in-memory access token, so an[Authorize]page opened by a deep link, an F5, or right-click "open in new tab" would bounce to/logineven for a signed-in user. The interface doc says exactly that (ISessionCookieSync.cs:3-7). The cookie is the one thing both sides can see, so keeping it in step with the in-memory token is what makes fresh GETs work.[Rubric §26, Front-End Security]assesses where browser credentials live. The target is an HttpOnly cookie, unreadable from JS, rather thanlocalStorage.[Rubric §25, Navigation, Routing & Information Architecture]assesses whether deep links behave. This contract is the reason a bookmarked authorized route renders instead of redirecting.
- Walkthrough: two members, both returning
Task<bool>:truewhen the cookie jar was updated,falsewhen the write failed or could not be attempted (a non-2xx answer, a dropped connection, JS interop unavailable).SyncAsync(accessToken, refreshToken)writes the pair (ISessionCookieSync.cs:15) andClearAsync()removes it (line 20). The two callers treat the answers differently on purpose. A failed sync is surfaced:WasmTokenStorageServicethrowsInvalidOperationExceptionwhenSyncAsyncreturnsfalse(MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/Tokens/WasmTokenStorageService.cs:103-106), soAuthUIServicereportsAuth.TokenStorageUnavailableinstead of a sign-in that silently ends at the first access-token expiry, when no cookie exists to refresh from (comment at lines 64-67;ServerTokenStorageServicedoes the same atServerTokenStorageService.cs:131). A failed clear is discarded (_ = await sessionCookieSync.ClearAsync(),WasmTokenStorageService.cs:115), because sign-out proceeds locally whatever the cookie endpoint answered. - Why it's built this way: the shape is the client half of
ADR-022, which decided
the BFF-style
mmca_auth_access/mmca_auth_refreshHttpOnly cookie pair and the/auth/session/tokenhydration endpoint. Keeping it an interface (rather than calling JS interop inline from token storage) is what lets a bUnit or unit test drive the storage services with a mock and no browser, whichWasmTokenStorageServiceTestsdoes (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Auth/WasmTokenStorageServiceTests.cs:29). - Where it's used: registered by the dedicated extension
AddClientAuthSessionCookieSync(), whichTryAddScopedsJsFetchSessionCookieSync(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:240-244); the doc there records that both the Blazor Server host and the WebAssembly client call it (lines 165-169).
SameOriginProxyHeaders
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/SameOriginProxyHeaders.cs:11· Level 0 · class (static)
- What it is: two constants naming the fixed CSRF request header the same-origin API proxy
requires on every state-changing request:
CsrfHeaderName = "X-CSRF"(SameOriginProxyHeaders.cs:14) andCsrfHeaderValue = "1"(line 17). - Depends on: nothing. It lives in
MMCA.Common.UIrather thanMMCA.Common.UI.Webso the client that stamps the header and the server that checks it share one definition. - Concept introduced, a custom header as a CSRF signal. A cross-site page can make a browser send
a simple POST carrying the user's cookies, but it cannot add a custom header without a CORS
preflight. The proxy answers
OPTIONSitself with no CORS grant and refuses any request whoseOriginorSec-Fetch-Sitenames another origin, so the header is defense in depth behind that origin check; the value carries no secret (SameOriginProxyHeaders.cs:3-10).[Rubric §26, Front-End Security]assesses cookie-borne credentials against cross-site request forgery. Once the bearer moves into an HttpOnly cookie, a header the browser will not attach on a cross-site request is what distinguishes the app's own calls.
- Walkthrough: two
const stringmembers and no behavior. Beingconst, both values are baked into each caller at compile time. - Why it's built this way: one shared type instead of a literal per call site keeps the stamp and the check from drifting apart. The proxy design is ADR-131.
- Where it's used: stamped by
SameOriginProxyRequestHandler(MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/SameOriginProxyRequestHandler.cs:19-20) and byNotificationHubServiceon the SignalR connection options (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/NotificationHubService.cs:363); checked bySameOriginApiProxyEndpointinHasCsrfHeader, which demands exactly one value equal toCsrfHeaderValue(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SameOriginApiProxyEndpoint.cs:110-113); stripped before forwarding bySameOriginProxyTransformer(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SameOriginProxyTransformer.cs:61). Covered bySameOriginProxyClientTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/SameOriginProxyClientTests.cs:27).
UserAgentSummary
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/UserAgentSummary.cs:18· Level 0 · class (internal, static)
- What it is: a deliberately tiny
User-Agentreader that returns the two words a person recognizes their own device by, the browser and the platform, for the signed-in-devices page. - Depends on: nothing first-party; only
string.ContainswithStringComparison.OrdinalIgnoreCase. - Concept introduced, scoping a parser to the question actually asked. The class doc argues the
design rather than describing it (
UserAgentSummary.cs:6-12): a device list only has to let someone answer "is that me?", so a full UA database buys precision nobody reads, while the browser-and-platform pair separates a phone from a work laptop. Anything unrecognized reportsnull, and the page supplies its own "unknown device" wording rather than dumping the raw header, which is neither readable nor localizable.[Rubric §27, Internationalization & Localization]assesses whether user-visible text survives translation. This is the sharpest example in the package: the two parts are returned separately and never joined, because composing "Chrome on Windows" in code would hard-code English word order. The caller formats them through a resource string (UserAgentSummary.cs:13-16, and ADR-027 is named there).[Rubric §32, Dependency & Supply-Chain]applies to what is absent: no UA-parsing library and no data file to keep current, which is a real dependency avoided for a cosmetic feature.
- Walkthrough:
Browsers, eleven(Token, Name)pairs in most-specific-first order (UserAgentSummary.cs:25-38). Order is load-bearing and the comment says why (lines 20-24): every Chromium browser also says "Chrome", and Chrome and Edge both say "Safari", soEdg/,EdgiOS/andEdgA/come beforeOPR/, which comes beforeCriOS/andChrome/, which come beforeSafari/.Platforms, ten pairs with the same rule (lines 44-56):Windows PhonebeforeWindows,Mac OS XandMacintoshbeforeLinux, because an iPad reports "Macintosh" in desktop mode and Android reports "Linux" (lines 40-43).Parse(string? userAgent)(line 66) returns(null, null)for a missing or blank header (lines 68-71), otherwise runs the shared matcher over each table and returns the pair (line 73).Match(userAgent, candidates)(line 76) walks the table in order and returns the first name whose token appears case-insensitively, ornull(lines 78-86).
- Why it's built this way: two ordered tables plus one loop is the entire implementation, so
adding a browser is one line and the ordering rule is visible at the point it matters. It is
internalbecause nothing outside the package should treat it as a UA parser. - Where it's used: exactly one call site,
Sessions.DescribeDevice(MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Sessions.razor.cs:239), whoseswitchcovers all four null combinations and falls back to a localized "unknown device" string (lines 183-189). Pinned byUserAgentSummaryTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Auth/UserAgentSummaryTests.cs:13). The page itself is the UI half of ADR-097.
ConfigurationOAuthUISettings
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth.OAuth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/OAuth/ConfigurationOAuthUISettings.cs:13· Level 1 · class (sealed)
- What it is: the real
IOAuthUISettings: it computes provider availability once at construction from theOAuthconfiguration section, and covers both a server host and a WASM client with a single class. - Depends on:
IOAuthUISettings(the contract it implements); externallyMicrosoft.Extensions.Configuration(IConfiguration,IConfigurationSection). - Concept introduced, one class over two configuration shapes. A server host holds the actual
OAuth client ids, so "is Google available" is answered by whether
OAuth:Google:ClientIdis populated. A WASM client must never receive a client id, so it is handed pre-computedOAuth:GoogleEnabledflags through its runtime configuration endpoint instead (ConfigurationOAuthUISettings.cs:5-12). The class accepts either signal.[Rubric §26, Front-End Security]assesses what configuration reaches the browser. The WASM path carries availability flags only, never the client id, and the class shape is what makes that possible without a second implementation.[Rubric §15, Best Practices & Code Quality]assesses duplication. One class, one rule, three providers.
- Walkthrough: three get-only auto-properties (
ConfigurationOAuthUISettings.cs:16,19,22) set in the constructor (line 24), which null-guards the configuration (line 26), takes theOAuthsection (line 28) and evaluates each provider through the shared helper (lines 29-31).IsProviderEnabled(oauth, provider)(line 34) is the whole rule: parse{provider}Enabledas a bool (line 36), and return true when that flag is set or when{provider}:ClientIdis non-empty (line 37). Because the values are read once into properties, a render pass never re-walks configuration. - Why it's built this way: the flag-or-client-id disjunction is what lets the same type serve both
hosts. The server side of the pairing is documented from the API package, which notes that the same
OAuth:{Provider}:ClientIdkeys the API reads are what this class reads for itsGoogleEnabled/GitHubEnabledanswers (MMCA.Common/Source/Presentation/MMCA.Common.API/Authentication/ExternalAuthExtensions.cs:17). The federated-login design behind the flags is ADR-036. - Where it's used: MMCA.ADC registers it with
AddSingleton(which replaces the framework'sTryAddSingletondefault regardless of ordering) on the Blazor Server head (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:89), the WASM client (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web.Client/Program.cs:38) and MAUI (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/MauiProgram.cs:101, whose comment at line 78 explains that the MAUI registration goes beforeAddUISharedbecause that callTryAdds the default). The server head also projects the resolved flags to the WASM client through its/client-configendpoint (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:265).
DefaultOAuthUISettings
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth.OAuth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/OAuth/DefaultOAuthUISettings.cs:7· Level 1 · class (internal, sealed)
- What it is: the framework's no-op
IOAuthUISettings, which reports every provider as unavailable so an app that has not configured external login shows no social buttons. - Depends on:
IOAuthUISettingsonly. - Concept reinforced, the Null Object as a registration default (the same move
NullNotificationScopeProvidermakes for notification scoping).[Rubric §2, Design Patterns]assesses whether a pattern removes branching: because a default is always registered, the login page injects the interface unconditionally and never tests whether one exists. - Walkthrough: the entire type is one line,
internal sealed class DefaultOAuthUISettings : IOAuthUISettings;(DefaultOAuthUISettings.cs:7). It has no members becauseIOAuthUISettingsgives all three propertiesfalse-returning default implementations; the class doc records the contract, that downstream apps override this registration to enable specific providers (lines 3-6). - Why it's built this way:
internalbecause nothing outside the package should name it, and a semicolon body because the default interface members already say everything. It is registered withTryAddSingleton(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:204), so a host that registers first keeps its own, and a host that registers afterwards withAddSingletonwins the resolution. - Where it's used: resolved as
IOAuthUISettingsin every host that has not registered its own, which today is all of MMCA.Store's UI heads and MMCA.Helpdesk.
OAuthFlowStateStore
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth.OAuth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/OAuth/OAuthFlowStateStore.cs:20· Level 1 · class (sealed)
- What it is: binds an OAuth authorize-redirect to the client that started it, by minting a random
statevalue, persisting it with a start time, and later checking that the value the provider's callback returns matches. It is the anti-CSRF half of the flow described by ADR-036. - Depends on:
ILocalCacheStore(device-local storage, injected,OAuthFlowStateStore.cs:20) for persistence,TimeProvider(injected, defaulted toTimeProvider.System,OAuthFlowStateStore.cs:20,31) for the expiry check,System.Security.Cryptography.RandomNumberGeneratorto mint the state value, and its own private record PendingAttempt as the stored shape. - Concept introduced, device-local state binding for a redirect-based flow. A browser or MAUI OAuth
challenge leaves the app entirely and comes back on a different navigation, so nothing in memory
survives the round trip; the only thing that can prove the callback belongs to the request this
client made is a value written to durable storage before the redirect and checked after it.
[Rubric §11, Security]assesses this exact class of defense:BeginAsyncmints the value withRandomNumberGenerator.GetHexString(32, lowercase: true)(OAuthFlowStateStore.cs:58), a cryptographically strong source, notGuid.NewGuid()or a counter. - Walkthrough:
StorageKey = "auth.oauth-flow"(OAuthFlowStateStore.cs:23) andAttemptLifetime = 10minutes (OAuthFlowStateStore.cs:29, "comfortably longer than a provider round trip and far shorter than a session, so an abandoned attempt cannot be revived days later").IsEnforced(OAuthFlowStateStore.cs:39) reportsstore.IsAvailable:falseonly on a host that registered neither the browser nor the native local-storage capability, in which case the caller keeps its prior behavior because nothing durable can be written across the redirect.BeginAsync(OAuthFlowStateStore.cs:51) returnsnullimmediately when storage is unavailable (53-56), otherwise mints the state, stores aPendingAttemptunderStorageKey(58-61), then reads the attempt back and compares its state ordinally (63-65). The read-back exists because the browser store swallows a failed write; if the attempt was not kept (storage full, site data blocked, private mode) it throwsInvalidOperationException(66-69, documented at46-50), so the failure surfaces before the redirect for the page to explain rather than one redirect later as an unexplained refusal. On success it returns the state for the caller to append to the challenge URL (72).TryCompleteAsync(OAuthFlowStateStore.cs:84) returnstrueunconditionally when storage is unavailable (86-89, matchingBeginAsync's no-op), otherwise reads and unconditionally removes the pending attempt (91-92, "removed either way, so a value is good for exactly one completion"), and fails if there was none or it expired (94-97). When a pending attempt exists, it accepts only a non-emptyreturnedStatethat matches exactly, ordinal comparison (102-103). An absent or empty value is refused: every legitimate flow round-trips the state, and accepting an omitted parameter would let a pasted link bypass the binding (comment at99-101, doc at76-80).
- Why it's built this way: ADR-036 (
Website/docs-src/adr/036-external-oauth-login.md) is the OAuth design this binds into; the single-use, time-boxed local record, together with refusing a completion that omits the state, is what keeps a replayed, stale, or forged callback from being accepted. - Where it's used: constructed inside
MMCA.Common/Source/Presentation/MMCA.Common.UI.Maui/Capabilities/Auth/MauiExternalAuthBroker.csfor the native OAuth callback path, and registered inMMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:174(TryAddScoped). Pinned byOAuthFlowStateStoreTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Auth/OAuthFlowStateStoreTests.cs).
AuthDelegatingHandler
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/AuthDelegatingHandler.cs:14· Level 1 · class (sealed)
- What it is: the
DelegatingHandlerthat attaches the stored JWT as aBearerheader on every outgoing API request, so no UI service ever sets anAuthorizationheader by hand. One request can opt out through theSkipBearerrequest option. - Depends on:
ITokenStorageService(its only constructor parameter); externalsSystem.Net.Http.Headers.AuthenticationHeaderValueandDelegatingHandler. - Concept introduced, the HTTP message-handler pipeline.
HttpClientcomposes handlers into a chain, each free to inspect or mutate a request before passing it to the next. Registering this one on the named"APIClient"client means auth is applied once, at the transport, for every typed service built on top of it.[Rubric §6, CQRS & Event-Driven Design]assesses whether concerns like auth are centralized rather than repeated per call. This is the client-side twin of the server's middleware pipeline: one registration covers every request.[Rubric §1, SOLID]shows in the single responsibility. The handler knows nothing about login, refresh, or expiry; it asks storage for whatever token exists and moves on.
- Walkthrough:
SkipBearer(AuthDelegatingHandler.cs:22) is a public staticHttpRequestOptionsKey<bool>named"MMCA.Common.UI.Auth.SkipBearer". The token refresh POST sets it: that endpoint is anonymous, and reading token storage from inside a refresh would re-enter the very acquisition that is waiting for the request to complete (doc at lines 17-21).SendAsync(request, cancellationToken)(AuthDelegatingHandler.cs:25) first checks the option: when it is set totruethe request passes straight tobase.SendAsyncwithout touching token storage at all (lines 29-32). A request that already carries anAuthorizationheader also gets no stored token (theAuthorization is nullcheck at line 34): the forced-refresh replay sets the token it just acquired, and replacing it with the stored one would resend the token the server just rejected. Otherwise it awaitsGetAccessTokenAsync()(line 36), and only when the result is non-blank setsrequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token)(lines 37-40) before delegating tobase.SendAsync(line 43). An anonymous call therefore goes out with no header at all rather than an empty one, which matters for the endpoints that are deliberately anonymous. Note the freshness work happens inside the token service: by the time this handler sees a token, a stale one has already been re-acquired.
- Why it's built this way: a handler rather than a base-class helper, because the pipeline applies
to everything the named client sends, including calls made by code that never inherits from a
framework base. It is registered
AddTransient(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:114), the lifetimeAddHttpMessageHandlerexpects. - Where it's used: added to the
"APIClient"pipeline alongside the culture handler (DependencyInjection.cs:146-147, with the intent stated at lines 75-76). One documented bypass exists:AuthenticatedServiceBasebuilds a client with the token set directly for the cases where the pipeline is not in play (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/AuthenticatedServiceBase.cs:55). TheSkipBeareroption is set byDirectApiTokenRefresheron its refresh request (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/Tokens/DirectApiTokenRefresher.cs:61). On a WebAssembly client of a host running the same-origin proxy,SameOriginProxyRequestHandlersits inside this handler and strips whatever header it attached (DependencyInjection.cs:148-155). Covered byAuthDelegatingHandlerTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Auth/AuthDelegatingHandlerTests.cs:16) and by a DI resolution test that exists because the pipeline must be able to construct it (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/ApiClientRegistrationTests.cs:29).
JsFetchSessionCookieSync
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/JsFetchSessionCookieSync.cs:13· Level 1 · class (sealed)
- What it is: the
ISessionCookieSyncimplementation. It calls two small JS helpers that issue a browser-sidefetch, so the resultingSet-Cookielands in the user's cookie jar on both Blazor Server and WebAssembly. - Depends on:
ISessionCookieSync(the contract) andIJSRuntime(Microsoft.JSInterop), plus themmcaAuthCookieobject defined in the package's static web assetMMCA.Common/Source/Presentation/MMCA.Common.UI/wwwroot/mmca-auth-cookie.js:4. - Concept introduced, why the fetch has to come from the browser. On Blazor Server the code runs on
the server, so a server-issued HTTP call would put the cookie in the server's own handler, not the
user's browser. Routing the call through JS interop makes the browser the one issuing the request,
which is the only way the
Set-Cookiereaches the right cookie jar (JsFetchSessionCookieSync.cs:6-8).[Rubric §26, Front-End Security]again: the tokens transit JS only for this one same-origin POST and are never persisted anywhere JS can read afterwards.[Rubric §29, Resilience & Business Continuity]assesses degradation. Every interop failure is caught and turned into afalseanswer, so a prerender pass or a disconnected circuit cannot throw out of a cookie write, yet the caller still learns the write did not happen.
- Walkthrough:
IsInteropUnavailable(ex)(JsFetchSessionCookieSync.cs:15-16) is the shared exception filter, naming the four types that mean "there is no live JS runtime right now":InvalidOperationException,JSDisconnectedException,JSExceptionandOperationCanceledException.SyncAsync(accessToken, refreshToken)(line 19) returnsInvokeAsync<bool>("mmcaAuthCookie.set", accessToken, refreshToken)(line 23): the script itself reports whether the endpoint answered 2xx. An interop failure returnsfalse, the comment noting nothing was written (lines 25-29).ClearAsync()(line 33) returnsInvokeAsync<bool>("mmcaAuthCookie.clear")(line 37) under the same filter, and an interop failure again returnsfalsebecause the cookie was not cleared by this call (lines 39-43). The class doc records that the script tries the clear twice (JsFetchSessionCookieSync.cs:9-11).
- Why it's built this way: a shared static filter rather than two duplicated
whenclauses keeps the definition of "interop unavailable" in one place. Reporting rather than swallowing silently is what lets the token storage services refuse a sign-in whose cookie never landed (seeISessionCookieSync). The cookie contract itself belongs to ADR-022. - Where it's used:
TryAddScopedbehindISessionCookieSyncbyAddClientAuthSessionCookieSync()(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:242); consumed byWasmTokenStorageServiceandServerTokenStorageService. - Caveats / not-in-source: the
mmcaAuthCookie.set/.clearJS implementations live inmmca-auth-cookie.js, outside this unit; only the C# side is described here.
JwtAuthenticationStateProvider
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/JwtAuthenticationStateProvider.cs:13· Level 1 · class (sealed)
- What it is: the custom
AuthenticationStateProviderthat turns the stored JWT into theClaimsPrincipalBlazor'sAuthorizeViewand[Authorize]read, and pushes a new state immediately on login and logout. - Depends on:
ITokenStorageService(its only constructor parameter); externalsMicrosoft.AspNetCore.Components.Authorization.AuthenticationStateProvider,System.Security.ClaimsandJwtSecurityTokenHandler. - Concept introduced, Blazor's auth-state contract. Blazor does not know about JWTs; it asks an
AuthenticationStateProviderfor anAuthenticationStateand re-renders everyCascadingAuthenticationStateconsumer when the provider says the state changed. This class is the adapter between "there is a token in storage" and that framework contract.[Rubric §19, State Management & Data Flow]assesses how a cross-cutting piece of UI state propagates. Notifying rather than reloading is the whole point: a sign-in updates the navbar and every guarded fragment without a page refresh (lines 55-58).[Rubric §11, Security]assesses the trust boundary, and the class doc draws it: claims are extracted client-side without server validation to keep the UI responsive, and the WebAPI performs full token validation on every request (lines 7-11).
- Walkthrough:
AnonymousState, a single staticAuthenticationStateover an emptyClaimsIdentity(JwtAuthenticationStateProvider.cs:15-16). Because aClaimsIdentitywith no authentication type reportsIsAuthenticated == false, this one shared instance is the "signed out" answer.GetAuthenticationStateAsync()(line 22) reads the token (line 26) and returns the anonymous state on a blank token (lines 27-30), an unreadable token (lines 33-36), or an expired one (ValidTo < DateTime.UtcNow, lines 39-42). On success it builds aClaimsIdentityfrom the token's claims with the authentication type"jwt", and the comment at line 44 records why that string matters: naming an authentication type is what makes the identityIsAuthenticated. A barecatch(lines 49-52) turns any remaining failure, including JS interop being unavailable, into anonymous rather than an exception inside a render.NotifyUserAuthentication(token)(line 59) rebuilds the principal the same way and calls the baseNotifyAuthenticationStateChanged(line 65). It does not consult storage, because the caller has just been handed the token.NotifyUserLogout()(line 71) pushesAnonymousStateback out.
- Why it's built this way: falling back to anonymous on every failure is the safe direction for a
UI gate, since the API rejects anything the client wrongly let through. Keeping the two notify
methods public (rather than internal to the auth service) is what lets
AuthUIServicedrive the state transition at the exact moment tokens change; it does so behind anis JwtAuthenticationStateProvidertype test (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/AuthUIService.cs:150,199,207,354), so a host that registered a different provider still works. - Where it's used: registered against
AuthenticationStateProvideron every head (MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:116,MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web.Client/Program.cs:46,MMCA.Store/Source/Hosts/UI/MMCA.Store.UI/MauiProgram.cs:98). Covered byJwtAuthenticationStateProviderTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Auth/JwtAuthenticationStateProviderTests.cs:14);AuthUIServiceTestsconstructs a real one rather than a double, precisely because the type test above would not match a mock (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Auth/AuthUIServiceTests.cs:67-69).
SameOriginProxyRequestHandler
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/SameOriginProxyRequestHandler.cs:11· Level 1 · class (internal, sealed)
- What it is: the innermost handler of the
"APIClient"pipeline on a WebAssembly client whose host runs the same-origin API proxy. It removes anyAuthorizationheader and stamps the proxy's CSRF header on every request (SameOriginProxyRequestHandler.cs:3-10). - Depends on:
SameOriginProxyHeadersfor the header name and value; externalDelegatingHandler. - Concept introduced, the browser holds no usable bearer. Behind the proxy the client only ever
holds a claims-only token; the proxy attaches the real bearer server-side from the HttpOnly session
cookie. Any
Authorizationheader the outer handlers (such asAuthDelegatingHandler) or a service'sDefaultRequestHeadersattached is therefore noise at best, so this handler clears it rather than trusting every caller to know.[Rubric §26, Front-End Security]assesses whether a usable credential is reachable from browser code. Here it is not, and the transport enforces that for every request.
- Walkthrough: one override.
SendAsync(request, cancellationToken)(SameOriginProxyRequestHandler.cs:14) guardsrequestwithArgumentNullException.ThrowIfNull(line 16), setsrequest.Headers.Authorization = null(line 18), removes any existing CSRF header and adds exactly one withTryAddWithoutValidation(lines 19-20), then delegates tobase.SendAsync(line 21). Removing first guarantees a single value, which is what the proxy's check demands. - Why it's built this way: registering it innermost means it sees the request after every other
handler and service has had its say, so one place decides what reaches the proxy. It is
registered only when
Api:SameOriginApiEndpointis configured, so every other client (Blazor Server, MAUI, a WebAssembly client of a host that did not opt in) keeps its pipeline unchanged (MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:116-121). The proxy design is ADR-131. - Where it's used:
AddTransientandAddHttpMessageHandlerinsideAddUIShared()behind theusesSameOriginProxycheck (DependencyInjection.cs:150-155), after the auth and culture handlers (lines 124-125). The proxy side that reads the CSRF header isSameOriginApiProxyEndpoint.
IEmailConfirmationUIService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/IEmailConfirmationUIService.cs:21· Level 3 · interface
- What it is: the client-side contract for the email-confirmation flow: redeeming a confirmation link's token, and asking for a fresh one when the first has expired or was never received.
- Depends on:
Result; externals: BCL (Task,CancellationToken,string). - Concept reinforced, the bespoke UI service contract (introduced at
IAuthUIService).[Rubric §18, UI Architecture & Component Design]and[Rubric §26, Front-End Security](assesses that a resend cannot be used to enumerate registered addresses). Two members, both returningResultso a page branches on the outcome instead of catching.ResendEmailConfirmationAsync's own doc comment states the anti-enumeration rule directly, that the endpoint answers 202 whether or not the address holds an account, so a success means "accepted", never "this address exists" (IEmailConfirmationUIService.cs:31-33). The class-level remarks also record why there is no retry: the token is single-use and the resend spends a per-address request budget, so a repeat is a second request rather than a replayed response (IEmailConfirmationUIService.cs:16-19). - Walkthrough: two methods.
ConfirmEmailAsync(string email, string token, CancellationToken cancellationToken = default)(IEmailConfirmationUIService.cs:28) redeems a single-use token viaPOST auth/confirm-email; the doc comment records that the endpoint returns the same generic rejection for every bad token (IEmailConfirmationUIService.cs:23-27).ResendEmailConfirmationAsync(string email, CancellationToken cancellationToken = default)(IEmailConfirmationUIService.cs:38) requests a fresh link viaPOST auth/send-email-confirmation, always reported as success at this layer regardless of whether the address is known.
- Why it's built this way: a narrow interface here, rather than the generic
IEntityService<,>, matches the same reasoningIAuthUIServicedocuments: confirming an email is not a CRUD operation on a resource with an identity, so it gets its own contract instead of being bent to fit one ([Rubric §14, Testability]). - Where it's used: implemented by
EmailConfirmationUIService; injected byMMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/ConfirmEmail.razor.csand byMMCA.Common/Source/Hosting/MMCA.Common.Testing.UI/Pages/ConfirmEmailPageTestsBase.cs; registered inAddUIShared()(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs).
EmailConfirmationUIService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/EmailConfirmationUIService.cs:23· Level 4 · class (sealed)
- What it is: the
IEmailConfirmationUIServiceimplementation. It posts to the two confirmation endpoints and translates the response through the sharedHttpResultExecutor/ProblemDetailsResultReaderpath used across the auth services. - Depends on:
IEmailConfirmationUIService(the contract it implements);IHttpClientFactory(its only constructor parameter,EmailConfirmationUIService.cs:23);HttpResultExecutorandProblemDetailsResultReader(MMCA.Common/Source/Core/MMCA.Common.Shared/Http/ProblemDetailsResultReader.cs) for the transport and response-translation work. - Concept reinforced, one shared execution path for every anonymous auth POST. Both members funnel
through the private
PostOnceAsync<TRequest>helper, so the retry policy and response translation are written once rather than duplicated per endpoint.[Rubric §15, Best Practices & Code Quality]assesses duplication; the two public methods are each a single expression forwarding to the shared helper (EmailConfirmationUIService.cs:31-35).[Rubric §29, Resilience & Business Continuity]is answered by what the helper deliberately does not do: no retry, because a repeat here is a second request (a spent token, a second email), never a replayed response (EmailConfirmationUIService.cs:41-42).
- Walkthrough:
ApiClientName = "APIClient"(EmailConfirmationUIService.cs:25) names the shared named client.ConfirmEmailAsync(line 28) posts aConfirmEmailRequesttoauth/confirm-email.ResendEmailConfirmationAsync(line 32) posts aSendEmailConfirmationRequesttoauth/send-email-confirmation.PostOnceAsync<TRequest>(relativeUrl, body, cancellationToken)(line 35) creates a freshHttpClientfrom the factory (line 39), posts the body withPostAsJsonAsync(lines 43-44) and reads the result withProblemDetailsResultReader.ReadAsync(line 46), all wrapped inHttpResultExecutor.ExecuteAsyncfor the shared transport-fault translation.
- Why it's built this way: a fresh
HttpClientper call (rather than a stored field) matches how the other anonymous auth services in this package are built, since the client is cheap to create from the factory and this service holds no other per-call state. - Where it's used: covered by
EmailConfirmationUIServiceTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Auth/EmailConfirmationUIServiceTests.cs); registered againstIEmailConfirmationUIServiceinAddUIShared()(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs).
IAuthUIService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/IAuthUIService.cs:18· Level 5 · interface
- What it is: the single client-side authentication contract: login, register, OAuth code exchange, logout, refresh, the three password flows, the signed-in-devices list with revoke, and sign-out of every device. Every auth page in the framework talks to this and nothing else.
- Depends on:
Resultand its generic form fromMMCA.Common.Shared.Abstractions; theMMCA.Common.Shared.AuthcontractsLoginRequest,RegisterRequest,AuthenticationResponseandRefreshSessionSummaryResponse; andErrorTypefor the failure kinds it documents. Implemented byAuthUIService. - Concept introduced, the failure travels with the call. The interface doc states the rule
(
IAuthUIService.cs:11-16): every call that talks to the API returns aResultcarrying the server's own errors, so the failure arrives with the call rather than on aLastErrorproperty that the next call would overwrite. Pages render it throughMMCA.Common.UI.Common.ResultUiExtensions(ResultUiExtensions).[Rubric §9, API & Contract Design]assesses whether return types carry meaning. Three different shapes appear here on purpose, and each deviation is argued in source rather than assumed.[Rubric §24, Forms, Validation & UX Safety]assesses whether a form can show the server's real message. Because the failure is the return value, a login page can bind the error next to the field without a second lookup.[Rubric §11, Security]shows in two documented behaviors: the anti-enumeration contract on password reset, and the OAuth exchange keeping tokens out of the address bar.
- Walkthrough: eleven members, in the order they appear.
LoginAsync(LoginRequest, ct)andRegisterAsync(RegisterRequest, ct)both returnResult<AuthenticationResponse>and both store tokens on success (IAuthUIService.cs:21,24).ExchangeOAuthCodeAsync(code, ct)(line 31) trades a single-use completion code carried in the redirect URL for the token pair throughauth/oauth/exchange, stores the tokens and notifies auth state; the doc's closing sentence names the reason for the indirection, keeping tokens out of the address bar (lines 26-30).LogoutAsync()(line 39) is the deliberate no-Resultmember: it revokes the server-side refresh sessions and clears local storage, and returns nothing because the local sign-out happens whatever the server answered. A user who asked to leave must never be kept signed in by a failed network call (lines 33-38).TryRefreshTokenAsync(ct)(line 50) is the deliberateboolmember: it makes no API call of its own, since the host'sITokenRefresherowns the exchange, and its two states ("session still live", "session gone") are not errors to render. Afalseis not always a sign-out: when anISessionAwareTokenRefresherreports the attempt as transient (TokenAcquisition.IsUnavailable), the stored credential is kept and a later call tries again (lines 41-49).ChangePasswordAsync(currentPassword, newPassword, ct)(line 53) hitsauth/password.RequestPasswordResetAsync(email, ct)(line 60) hits the anonymousauth/forgot-password. The doc pins the semantics: the endpoint answers 202 for every well-formed address as an anti-enumeration measure, so a success means "accepted", never "an account exists" (lines 55-59).ResetPasswordAsync(email, token, newPassword, ct)(line 67) completes the reset via the anonymousauth/reset-password; an invalid, expired or already-consumed token comes back as a failure carrying the server's generic message (lines 62-66).GetSessionsAsync(ct)(line 75) returnsResult<IReadOnlyList<RefreshSessionSummaryResponse>>fromauth/my-sessions, newest first, and documents that exactly one row can carryIsCurrent, resolved server-side from the access token'ssidclaim (lines 69-74).RevokeSessionAsync(sessionId, ct)(line 84) signs one device out viaauth/revoke/{sessionId}; another account's session id (or a nonexistent one) answers 404 and arrives as anErrorType.NotFoundfailure, and revoking an already-revoked session answers the same 404 (lines 77-83).RevokeAllSessionsAsync(ct)(line 94) signs out of every device viaauth/revokeand, unlikeLogoutAsync, returns the server's answer as aResult. A failed revoke is reported rather than hidden, and the local session is left in place so the caller can say so and let the user retry; on success it also performs the same local sign-outLogoutAsyncdoes (lines 86-93). The pair is the clearest statement of the return-shape rule: the same endpoint, two members, because "leave this device" must never fail while "end every session" must never claim success it did not get.
- Why it's built this way: the interface is where the three return shapes are justified, and each justification is a behavior rule rather than a style preference. Keeping all eleven operations on one contract (rather than splitting sessions or password flows onto their own) matches how they are consumed: the shipped auth pages are themselves framework types, so there is one implementation and one registration. The device-list and revoke members are the UI surface of ADR-097; the OAuth exchange is the client end of ADR-036 and ADR-043.
- Where it's used: registered
TryAddScopedagainstAuthUIServicebyAddUIShared()(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:162, the comment at line 108 notingTryAddprevents duplicate registration when several hosts call in); injected by the shipped auth pages, includingLogin(MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Login.razor) andSessions, whose sign-out-everywhere action callsRevokeAllSessionsAsync(MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Sessions.razor.cs:191).
AuthUIService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/AuthUIService.cs:44· Level 6 · class (sealed)
- What it is: the one client-side service that owns a user's session on a Blazor or MAUI head. It
calls the WebAPI
auth/*endpoints (sign in, register, OAuth code exchange, change password, forgot and reset password, list and revoke devices, sign out), persists the returned token pair, and tells Blazor's authentication state that something changed, soAuthorizeViewand[Authorize]routes react without a page reload. Every method that talks to the API hands back a Result carrying the server's own error text, so no page has to interpret anHttpResponseMessage. - Depends on: first-party
IAuthUIService (the contract it implements,
AuthUIService.cs:51), ITokenStorageService (token persistence, injected atAuthUIService.cs:46), ITokenRefresher (the host-specific renewal path,AuthUIService.cs:47), JwtAuthenticationStateProvider (injected as the frameworkAuthenticationStateProviderbase type atAuthUIService.cs:48and pattern-matched back down), IPushRegistrationService (native push cleanup,AuthUIService.cs:49), IUiReadCache (optional, defaulted tonullatAuthUIService.cs:50),ILocalCacheStore(optional device-local document cache, defaulted tonull,AuthUIService.cs:51, wiped on sign-out alongside the read cache), HttpResultExecutor (transport-fault translation), ProblemDetailsResultReader (response translation), and the shared auth contracts LoginRequest, RegisterRequest, OAuthCodeExchangeRequest, ChangePasswordRequest, ForgotPasswordRequest, ResetPasswordRequest, AuthenticationResponse and RefreshSessionSummaryResponse. Externals:IHttpClientFactory,System.Net.Http.Json(PostAsJsonAsync,PutAsJsonAsync),System.Net.Http.Headers.AuthenticationHeaderValue, andMicrosoft.AspNetCore.Components.Authorization.AuthenticationStateProvider. - Concept introduced, the client-side session lifecycle as one service, with a sign-out that cannot
fail. Everything a session needs on the client (acquire a token pair, hold it, renew it, publish the
identity to the component tree, and tear all of that down) lives behind a single injectable interface,
so no component ever touches
localStorage, a Bearer header, or a token string.[Rubric §26, Front-End Security]assesses whether credentials are confined to a narrow, auditable surface in the browser: here the only code that reads or writes tokens is this service plus AuthDelegatingHandler, both of them going through ITokenStorageService rather than doing JS interop of their own.[Rubric §11, Security]assesses the end-to-end auth design. There are two sign-outs with opposite failure policies.LogoutAsyncis deliberately local-first: the remote revoke is best effort, and both the server call and the local clear are wrapped so a dropped connection can never strand a user inside a session they asked to leave (AuthUIService.cs:88-101,109-124,361-399).RevokeAllSessionsAsyncis server-first: it reports a failed revoke and only signs out locally once the server confirmed, so "signed out everywhere" is never claimed when it did not happen (AuthUIService.cs:127-148).[Rubric §19, State Management]assesses who owns mutable client state and when it is invalidated: this service is the single writer of auth state, and it is also the thing that empties the read cache, because on WebAssembly and MAUI the DI scope is the app lifetime, so cached rows would otherwise outlive the account that fetched them (AuthUIService.cs:33-37,390-394).[Rubric §18, UI Architecture]sees the same shape the entity services use, a typed service over the named"APIClient"returningResult, so pages render failures with ResultUiExtensions instead of catching exceptions.[Rubric §14, Testability]is served by taking all five collaborators through the primary constructor with no statics: AuthUIServiceTests drives the whole class through a stubHttpMessageHandler. - Walkthrough
- Two public error codes head the class.
TokenStorageUnavailableCode = "Auth.TokenStorageUnavailable"(AuthUIService.cs:57) is reported when authentication succeeded but the tokens could not be written because JS interop was unavailable (SSR prerender, or a render-mode transition), andMissingAccessTokenCode = "Auth.MissingAccessToken"(AuthUIService.cs:63) covers a 2xx whose body carried no access token, which means the response shape drifted. Both areconst string, so tests and pages branch on them without duplicating literals. The privateApiClientName = "APIClient"(AuthUIService.cs:65) names the shared client registered inMMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:124. LoginAsyncandRegisterAsyncare one-liners over the privateAuthenticateAsync, differing only in the relative URL,auth/loginandauth/register(AuthUIService.cs:68-73).ExchangeOAuthCodeAsync(AuthUIService.cs:76) guards the code client-side first: a blank code returnsError.Validation("Auth.OAuth.MissingCode", ...)without a round trip (AuthUIService.cs:78-82), then it posts an OAuthCodeExchangeRequest toauth/oauth/exchange(AuthUIService.cs:84). The single-use code arrives in the redirect URL, which is what keeps the tokens themselves out of the address bar (ADR-036,Website/docs-src/adr/036-external-oauth-login.md).AuthenticateAsync(AuthUIService.cs:294) is the shared body of all three. It posts the credential through HttpResultExecutor and reads the response withProblemDetailsResultReader.ReadAsync<AuthenticationResponse>(AuthUIService.cs:299-306), returns early on failure (308-311), then checks the access token is actually present and fails withMissingAccessTokenCodeif it is not (313-318). Only then does it calltokenStorageService.SetTokensAsyncinside atrythat converts anInvalidOperationExceptioninto aTokenStorageUnavailableCodefailure carrying the exception message as detail (320-345). The point of that branch is that valid credentials nothing can hold are a failure, not a silent no-op; on browser hosts it also fires when the HttpOnly cookie write reports failure, because the token storage services throw on afalsefrom ISessionCookieSync. Before returning that failure it cleans up after itself (326-339): the server already minted a session and the storage took the access token in memory before the cookie write failed, so it revokes the session throughTryRevokeAsync(authentication.AccessToken, ...)(331) and then clears the stored tokens (334, swallowing a secondInvalidOperationException), or the API client would behave signed in behind a page that reported failure. Finally it pattern-matches the injectedAuthenticationStateProviderdown to JwtAuthenticationStateProvider and callsNotifyUserAuthentication(accessToken)(348-351, andMMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/JwtAuthenticationStateProvider.cs:60). Theistest rather than a cast is what lets a host register a different provider without breaking sign-in.LogoutAsync(AuthUIService.cs:88) runs three steps, each isolated so a failure cannot stop the next. First the privateUnregisterPushAsync()(call at line 90). Second, if a token can be read (92-93), it calls the privateTryRevokeAsync(accessToken, CancellationToken.None)(line 98), which is the shared best-effort revoke: it creates an APIClient, sets the Bearer header by hand and postsauth/revokeinside a barecatchthat ignores every failure (109-124). The helper is shared with the login path that cannot store the session it was just issued (seeAuthenticateAsync), so the two revokes cannot drift. Third, the privateSignOutLocallyAsync()(line 101).RevokeAllSessionsAsync(AuthUIService.cs:127) posts the sameauth/revokethrough HttpResultExecutor on an authenticated client, reading it with the non-generic reader (129-137). Only when the result is a success does it runUnregisterPushAsync()thenSignOutLocallyAsync(), in the same order asLogoutAsyncso the push unregister still holds a valid access token (139-145); either way it returns the server'sResult(line 147). A failure leaves the local session intact for the caller to report and retry.TryRefreshTokenAsync(AuthUIService.cs:151) makes no HTTP call of its own. It asks the injectedtokenRefresherfor a token, through one of two paths (162-171). When the refresher is anISessionAwareTokenRefresher(pattern-matched at line 162) it callsTryAcquireAccessTokenAsyncand, if the answerIsUnavailable(a transient outcome that says nothing about the session, for the direct refresher a 409 refresh-superseded), returnsfalseimmediately (164-167) without clearing anything: the stored credential is kept for the next attempt, because clearing it would stop this client from ever presenting a token someone else rotated, which is what lets the server's reuse detection (BR-206) revoke the other holder's session (comment at156-160). Any other refresher falls back toAcquireAccessTokenAsync(line 170). Browser hosts renew through the same-origin cookie proxy so the refresh token never reaches JS (SameOriginProxyTokenRefresher) and MAUI renews straight from secure storage (DirectApiTokenRefresher). A null or blank answer means the session is gone, which this method treats exactly like a sign-out: clear tokens, clear the read cache and the local cache (also throughClearLocalCacheAsync()), notify logout, returnfalse(173-199). It spells these steps out inline rather than callingSignOutLocallyAsync(). A token that comes back is published withNotifyUserAuthenticationand answered withtrue(201-206). Theboolreturn is the honest type here, because neither outcome is an error a page would render (IAuthUIService.cs:41-50).- The password trio all wrap HttpResultExecutor.
ChangePasswordAsync(210) is the only one that authenticates: it builds a ChangePasswordRequest andPUTs it toauth/password(217-221).RequestPasswordResetAsync(226) andResetPasswordAsync(243) deliberately use the plain factory client with no Bearer header (232,251), because a reset must not be bound to whatever session happens to be open (comment at230-231). The comment at236-237records the contract that matters:auth/forgot-passwordanswers 202 for every well-formed address, so a success never means "this account exists". - The two session methods back the devices page.
GetSessionsAsync(261)GETsauth/my-sessionsand reads it with the generic reader intoIReadOnlyList<RefreshSessionSummaryResponse>(269-270).RevokeSessionAsync(275) postsauth/revoke/{sessionId}and reads it with the non-generic reader (282-285), because that endpoint answers 204 and ProblemDetailsResultReader's generic overload turns an empty 2xx body into anEmptyResponseCodefailure (MMCA.Common/Source/Core/MMCA.Common.Shared/Http/ProblemDetailsResultReader.cs:305-310). Picking the wrong overload there would turn every successful revoke into an error. - Private helpers close the class:
TryRevokeAsync(109, described underLogoutAsync) and five more.UnregisterPushAsync(361) callspushRegistration.UnregisterAsync()inside a barecatch(363-372): the Devices DELETE is authenticated, so it has to happen while the access token is still valid, and it is a no-op on web heads (ADR-044,Website/docs-src/adr/044-native-push-delivery.md).SignOutLocallyAsync(379) is the local half of every sign-out:tokenStorageService.ClearTokensAsync()undercatch (InvalidOperationException)for the interop-unavailable case (381-388), thenreadCache?.Clear()andClearLocalCacheAsync()(393-394), thenNotifyUserLogout()(396-399, and.../Services/Auth/JwtAuthenticationStateProvider.cs:71). The two#pragma warning disable CA1031blocks (118-120inTryRevokeAsync,367-369inUnregisterPushAsync) are deliberate and annotated in place: catching everything is the correct policy for a best-effort cleanup step.CreateAuthenticatedClientAsync(408) creates the APIClient and setsDefaultRequestHeaders.Authorizationfrom the stored token; its doc comment states plainly that it mirrors AuthenticatedServiceBase and cannot inherit it, because this is not an entity service and takes a different dependency set (402-407).ReadAccessTokenAsync(421) swallowsInvalidOperationExceptionfrom the store and returnsnull, so an SSR prerender proceeds tokenless and lets the API answer 401 like any other failure (427-432).ClearLocalCacheAsync(AuthUIService.cs:438) no-ops whenlocalCacheisnull, otherwise callslocalCache.ClearAsync()inside acatch (InvalidOperationException)for the same JS-interop-gone case the token clear guards against (445-452), so a device-local offline snapshot never survives to be read by the next account on the same device.
- Two public error codes head the class.
- Why it's built this way: ADR-051 (
Website/docs-src/adr/051-client-auth-token-lifecycle.md) is the record behind the split visible in the constructor: storage, renewal and orchestration are three different abstractions because each render mode (SSR prerender, Blazor Server, WebAssembly, MAUI) can hold and renew a credential differently, while the orchestration above them stays identical. The refresh path itself is server-side rotation with reuse detection (ADR-050,Website/docs-src/adr/050-jwt-refresh-token-rotation.md), generalized to one row per device by ADR-097 (Website/docs-src/adr/097-multi-device-refresh-sessions.md), which is what givesGetSessionsAsyncandRevokeSessionAsyncsomething to list and revoke. Browser hosts keep the refresh token in an HttpOnly cookie rather than in reachable storage (ADR-022,Website/docs-src/adr/022-browser-session-cookie-auth.md), which is exactly whyTryRefreshTokenAsyncdelegates instead of calling a refresh endpoint itself. ReturningResultinstead of throwing keeps every auth failure renderable: the API's Problem Details payload already carries the server's own wording and ErrorType, so the page shows that rather than a client-invented message. - Where it's used: registered
TryAddScoped<IAuthUIService, AuthUIService>()inMMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:162, so every host that adds the shared UI gets it. Consumers inside the shared UI are the auth pages (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Login.razor:204,Pages/Auth/Register.razor:161,Pages/Auth/OAuthComplete.razor:65,Pages/Auth/ForgotPassword.razor:81,Pages/Auth/ResetPassword.razor:122), the devices page Sessions (Pages/Auth/Sessions.razor.cs:79,119,165,191), and both shells (Layout/MainLayout.razor:108,Layout/NavMenu.razor:185, which callLogoutAsync). Downstream, the shared ChangePasswordCard callsChangePasswordAsync(MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/Auth/ChangePasswordCard.razor.cs:114), and the consumer profile pages (Profile) host that card rather than calling the service themselves. Note howForgotPassword.razorconsumes it: it discards theResultand swallows exceptions (Pages/Auth/ForgotPassword.razor:75-92), because the page must look identical whether or not the address exists. The component gallery substitutes NoOpAuthUIService, and AuthUIServiceTests (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Auth/AuthUIServiceTests.cs:37) pins the behavior end to end, including the storage-unavailable and missing-token branches (AuthUIServiceTests.cs:216,229). - Caveats:
TryRefreshTokenAsynchas no production call site in the workspace; the only callers are AuthUIServiceTests (AuthUIServiceTests.cs:508,454) and the gallery stub. Renewal in a running app happens further down, inside the storage service and the refreshers, so this method is a public entry point that nothing currently enters. Theauth/revokecall inLogoutAsyncis described in the comment as "fire-and-forget" (lines 94-96) but is in fact awaited (await TryRevokeAsync(...),AuthUIService.cs:98); the accurate reading is best-effort-and-ignored, and on a bad network the awaited call can add its full timeout to a sign-out. That same call passesCancellationToken.None, becauseLogoutAsynctakes none by design (IAuthUIService.cs:39). Finally,CreateAuthenticatedClientAsyncsets aDefaultRequestHeadersBearer while AuthDelegatingHandler is already attaching one to every APIClient request from the same store (DependencyInjection.cs:146,Services/Auth/AuthDelegatingHandler.cs:34-40), so the header is computed twice per authenticated call; the handler now skips its own lookup when the request already carries anAuthorizationheader (AuthDelegatingHandler.cs:34), so the stored token is read once here and the handler leaves that header alone, which is redundancy avoided rather than a defect. Behind the same-origin proxy both are then stripped by SameOriginProxyRequestHandler.
ISecureTokenStore
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth.Tokens·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/Tokens/ISecureTokenStore.cs:16· Level 0 · interface
- What it is: raw token persistence with no freshness semantics. It reads back exactly what was
written and never triggers a refresh, which is what separates it from the storage contract callers
actually consume,
ITokenStorageService. - Depends on: nothing first-party. Implemented by
MauiSecureTokenStoreover OS SecureStorage; consumed byDirectApiTokenRefresherand byMauiTokenStorageService. - Concept introduced, splitting storage from freshness to keep the graph acyclic. This interface
exists for a dependency-graph reason that the source states in full
(
ISecureTokenStore.cs:4-9):ITokenStorageService, the layer callers consume, depends onITokenRefresher; the refresher in turn depends on this raw store. The chain runs storage, then refresher, then raw store, with no loop. Collapse the two storage interfaces into one and the refresher would depend on the very acquisition that invoked it, which is a re-entrancy hazard at runtime, not just a diagram problem.[Rubric §1, SOLID]assesses whether a single interface has one reason to change. Here two responsibilities that look identical from the outside (read a token, write a token) are separated precisely because one of them is allowed to go to the network and the other is not.[Rubric §11, Security]assesses where credentials rest. The remarks record that only hosts which persist tokens themselves implement it: MAUI backs it with OS SecureStorage, while the browser hosts hold the access token in memory and keep the refresh token in an HttpOnly cookie, so they have no raw store to expose (ISecureTokenStore.cs:10-14).
- Walkthrough: four members, all verbatim.
GetAccessTokenAsync()reads the stored access token ornull(ISecureTokenStore.cs:19).GetRefreshTokenAsync()does the same for the refresh token (line 22).SetTokensAsync(accessToken, refreshToken)persists both, replacing whatever was there (line 25).ClearTokensAsync()removes both, the logout path (line 28).
- Why it's built this way: the interface is deliberately dumber than the one above it. Because it promises no freshness, an implementation can be a thin wrapper over a platform API with no policy, and the single-flight hydration policy lives once, higher up, in the storage services.
- Where it's used: registered on the MAUI head only,
services.AddScoped<ISecureTokenStore, MauiSecureTokenStore>()(MMCA.Common/Source/Presentation/MMCA.Common.UI.Maui/DependencyInjection.cs:105, with the rationale at lines 66-77). Injected intoDirectApiTokenRefresher(MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/Tokens/DirectApiTokenRefresher.cs:27) and mocked directly inDirectApiTokenRefresherTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Auth/DirectApiTokenRefresherTests.cs:32). - Caveats / not-in-source: no browser host implements it. Resolving it on a Blazor Server or WASM head is a DI failure by design, not an oversight.
ITokenRefresher
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth.Tokens·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/Tokens/ITokenRefresher.cs:17· Level 0 · interface
- What it is: a one-method contract for acquiring a fresh JWT access token, abstracting over the fact that each host holds its refresh credential somewhere completely different.
- Depends on: nothing first-party. Implemented by
SameOriginProxyTokenRefresheron the browser heads andDirectApiTokenRefresheron MAUI; consumed byWasmTokenStorageService,ServerTokenStorageService,MauiTokenStorageServiceandAuthUIService. - Concept introduced, one contract over two materially different security models. The interface
doc enumerates both (
ITokenRefresher.cs:4-15): on the browser the refresh token lives in an HttpOnly cookie and rotation happens server-side behind the same-origin/auth/session/tokenendpoint, so the refresh token is never exposed to JS; on MAUI the refresh token sits in OS SecureStorage and is exchanged directly against the API's cross-originauth/refresh.[Rubric §11, Security]assesses whether the strongest available mechanism is used per platform. A browser has an XSS surface and gets the cookie proxy; a native app has no DOM and gets direct token handling. The abstraction is what lets both be correct without a shared lowest common denominator.[Rubric §7, Microservices Readiness]shows in the fact that the two implementations talk to two different origins (the UI host for one, the API for the other) behind one signature.
- Walkthrough: a single member,
Task<string?> AcquireAccessTokenAsync(CancellationToken cancellationToken = default)(ITokenRefresher.cs:24). The nullable return is the whole error model:nullmeans no valid session exists, whether the refresh credential is missing, expired, or revoked (lines 15-19). There is no exception path a caller has to know about. - Why it's built this way: null-rather-than-throw matches how the callers use it. Token storage
calls this on a hot path (every outgoing request may hydrate) and "the session is gone" is an
ordinary outcome, not an exceptional one. Modelling it as an exception would force a
tryaround every hydrate. - Where it's used: registered per host,
AddScoped<ITokenRefresher, SameOriginProxyTokenRefresher>()on the Blazor Server and WASM heads (MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:115,MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web.Client/Program.cs:45) andAddScoped<ITokenRefresher, DirectApiTokenRefresher>()on MAUI (MMCA.Store/Source/Hosts/UI/MMCA.Store.UI/MauiProgram.cs:97).
TokenAcquisition
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth.Tokens·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/Tokens/TokenAcquisition.cs:9· Level 0 · class (sealed)
- What it is: the three-way outcome of a token-acquisition attempt: a token, a definitive "no
session", or a transient failure. It is a sealed value holder with a private constructor
(
TokenAcquisition.cs:11), so the only ways to get one are its three factories. - Depends on: nothing first-party. Produced by
ISessionAwareTokenRefresherimplementations (SameOriginProxyTokenRefresher,DirectApiTokenRefresher) and read byWasmTokenStorageService. - Concept introduced, making "absent" and "unknown" different values. A nullable token collapses
two very different facts: the server said there is no session, or the attempt failed and said
nothing. Treating the second as the first makes a signed-in user look anonymous after a 429 or a
dropped connection.
[Rubric §12, Performance & Scalability]: only the definitiveNoSessionlets storage skip repeat token POSTs, and the type, not an exception or a magic null, carries that distinction.[Rubric §11, Security]: a revoked or reused refresh token isNoSession, but a 409 on a superseded token isUnavailable(DirectApiTokenRefresher.cs:64-67), so the stored pair is not cleared by a race.
- Walkthrough:
NoSession(line 35), the static outcome for an answer that proves there is no session (the endpoint's 401);AccessTokenisnullandIsUnavailableis false.Unavailable(line 38), for an attempt that failed without saying anything about the session;IsUnavailableis true, so the next read should try again (lines 43-47).Acquired(accessToken)(line 52) callsArgumentException.ThrowIfNullOrWhiteSpace(line 54) and returns an outcome carrying the token, so a blank token can never masquerade as success.AccessToken(line 41) isnullfor both non-success outcomes.
- Why it's built this way: the two static singletons avoid allocation on the hot "no token" paths, and the private constructor keeps the invariant (a token implies not unavailable) unforgeable. Design rationale: ADR-051.
- Where it's used: returned by
TryAcquireAccessTokenAsyncin both refreshers, and consumed inWasmTokenStorageService.HydrateAsync(WasmTokenStorageService.cs:127-129) and the handoff session services (MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/HandoffSessionServices.cs). Covered throughWasmTokenStorageServiceTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Auth/WasmTokenStorageServiceTests.cs).
ITokenStorageService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth.Tokens·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/Tokens/ITokenStorageService.cs:8· Level 0 · interface
- What it is: the platform-agnostic token contract every other UI type depends on. Anything that needs a bearer token asks this, and never asks where the token is kept.
- Depends on: nothing first-party. Implemented by
WasmTokenStorageService,ServerTokenStorageServiceandMauiTokenStorageService. - Concept introduced, the freshness-checking storage layer. It shares its four member signatures
with
ISecureTokenStore, and the difference is entirely in the promise: this one may go and acquire a token, the raw store may not. Reading the two interfaces side by side is the fastest way to understand the auth layering in this package.[Rubric §3, Clean Architecture]assesses whether presentation code depends on abstractions rather than platform APIs. Nothing above this line namesSecureStorage,HttpContext, or a cookie.[Rubric §11, Security]assesses the storage decision itself, and the doc makes it explicit: browser hosts hold the access token in memory and mirror the refresh token to an HttpOnly cookie, neverlocalStorage; MAUI uses OS SecureStorage (ITokenStorageService.cs:3-7).
- Walkthrough:
GetAccessTokenAsync()(ITokenStorageService.cs:11),GetRefreshTokenAsync()(line 14),SetTokensAsync(accessToken, refreshToken)called after a successful login or refresh (line 17), andClearTokensAsync()for logout (line 20). - Why it's built this way: four methods is the smallest surface that covers the whole client auth lifecycle, and keeping it free of any freshness parameter means the policy (skew, single-flight) belongs to the implementation, where it can differ per host.
- Where it's used: injected into
AuthDelegatingHandler,JwtAuthenticationStateProvider,AuthUIService,AuthenticatedServiceBase(which uses it for the direct-token path that bypasses the delegating handler,MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/AuthenticatedServiceBase.cs:55) andNotificationHubServicefor the SignalR access-token provider. Test hosts substituteStubTokenStorageServiceandNullTokenStorageService(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Gallery/Stubs/NullTokenStorageService.cs:8).
JwtTokenInfo
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth.Tokens·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/Tokens/JwtTokenInfo.cs:9· Level 0 · class (static)
- What it is: one static predicate,
IsFresh, that answers whether a cached access token is still worth using or whether the caller should go and re-acquire one. - Depends on: nothing first-party. Externals:
System.IdentityModel.Tokens.Jwt(JwtSecurityTokenHandler) andDateTime.UtcNow. - Concept introduced, unvalidated client-side token inspection. Reading a JWT without checking its
signature looks alarming until you see what the answer is used for: it decides whether to call the
refresher, nothing else. The class doc states the boundary plainly, no signature validation because
the API validates every request (
JwtTokenInfo.cs:5-7).[Rubric §11, Security]assesses where trust decisions are made. A forged token that passed this check would still be rejected by the API on the first call, so the client-side read is an optimization, not an authorization.[Rubric §12, Performance & Scalability]assesses avoidable round trips. Without this check every outgoing request would have to hydrate; with it, a live token short-circuits the whole refresh path.
- Walkthrough:
IsFresh(string? token, TimeSpan skew)(JwtTokenInfo.cs:16) runs four guards and returnsfalseon all of them, so every uncertain case biases toward refreshing:- a null, empty, or whitespace token (lines 18-21);
- a token
JwtSecurityTokenHandler.CanReadTokenrejects, meaning it is not a readable JWT (lines 23-27); - otherwise it reads the token and compares
ValidTo > DateTime.UtcNow + skew(line 31), so the skew is a proactive margin: the token is called stale slightly before it truly expires; - and a parse blowing up as
ArgumentExceptionorFormatExceptionis caught and reported as not fresh (lines 33-36).
- Why it's built this way: a static pure function of two arguments is trivially testable and has no lifetime to manage, and the deliberate fail-to-false makes every failure mode converge on the one safe action (refresh). The exception filter is narrow on purpose: only the two malformed-input types are swallowed, so a genuinely unexpected failure still surfaces.
- Where it's used:
WasmTokenStorageService.GetAccessTokenAsyncwith a 30-second skew (WasmTokenStorageService.cs:27,52), and the same pattern inServerTokenStorageService(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Services/ServerTokenStorageService.cs:37).
IUiReadCache
MMCA.Common.UI ·
MMCA.Common.UI.Services.Caching·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Caching/IUiReadCache.cs:32· Level 0 · interface
- What it is: the contract for a per-circuit read-through cache sitting in front of the API client, so a page that re-reads the same list twice within a few seconds (a grid re-mounted by navigation, a lookup rendered in two components) does not pay for two round trips.
- Depends on: nothing first-party in its signature. Its freshness policy lives in
UiReadCacheOptions (named in the doc at
IUiReadCache.cs:17); its only framework implementation is UiReadCache. Externals:System.Diagnostics.CodeAnalysisfor the analyzer suppression at lines 28-31. - Concept introduced, a two-tier cache whose key shape is deliberately shared with the server.
[Rubric §23, Front-End Performance]assesses avoidable network work in the browser, and[Rubric §12, Performance & Scalability]the same question for the API behind it. The load-bearing design decision is not that a cache exists, it is what a key is: the relative URL, path plus the full query string, used verbatim (IUiReadCache.cs:9-15). That is the same key shape the server's authenticated output cache uses, whose policy setsCacheVaryByRules.QueryKeys = "*"so every query-string variant is its own entry (ADR-040). Mirroring the shape means the two tiers agree on what "the same read" is: a filter, page or sort change misses on both sides rather than being served a stale answer by one of them.[Rubric §26, Front-End Security]and[Rubric §30, Compliance, Privacy & Data Governance]both land onClear(). The cache is registered scoped, which is one instance per Blazor Server circuit but one per app lifetime on WebAssembly and MAUI, where the scope outlives a sign-out. The contract therefore states that the sign-out path callsClearso one account's reads can never be served to the next (IUiReadCache.cs:22-26,80-84).[Rubric §29, Resilience & Business Continuity]shows in the storage rule: only successful reads are stored, so a transient outage cannot pin an error in front of the user (IUiReadCache.cs:19-20).- Why the parameter is a
stringand not aUri. TheCA1054suppression (lines 28-31) is worth reading as a small design argument: the parameter is a cache key that happens to be spelled as a relative URL, it is compared by ordinal prefix and stored verbatim, andSystem.Uriwould re-encode and re-normalize it, which is exactly what must not happen to a key when the point is matching the server's key byte for byte. - The in-flight read race, and the generation counter that closes it. A read issued before a
write and completing after it would otherwise re-store exactly the value the write just
invalidated.
Generation(line 40) is a counter that moves on everyInvalidatePrefixandClear; a reader captures it before the GET and hands it back to the three-argumentSet, which drops the value when an invalidation happened in between (lines 34-39, 61-70).[Rubric §19, State Management & Data Flow]assesses exactly this kind of stale-write ordering.
- Walkthrough: six members, two of them default interface members.
long Generation => 0(line 40) is the invalidation generation. The default body returns a constant, so an implementation that does not track generations never drops a value.bool TryGetFresh<T>(string url, out T? value)(line 50) reports a fresh hit; a miss, an expired entry, or a disabled cache all read asfalse, and the doc notes that a hit stored under a different type also reads as a miss (line 45).void Set<T>(string url, T value)(line 59) stores a successfully read value stamped with the current time, and is a no-op when caching is disabled. The doc is explicit that only success values are ever passed here (line 58).void Set<T>(string url, T value, long generation)(line 70) stores only when no invalidation happened sincegenerationwas read; its default body forwards to the two-argumentSetand ignores the generation (lines 61-65, 70).void InvalidatePrefix(string routePrefix)(line 78) drops every entry whose key starts with the prefix, ordinally. That is how one endpoint's create, update or delete clears that endpoint's list, paged, lookup and by-id entries in a single call (lines 73-75).void Clear()(line 85) drops everything, for the sign-out case above.
- Why it's built this way: an interface (rather than a concrete helper) is what lets the whole
feature be optional. Both consumers take
IUiReadCache?with anulldefault, so a host that registers nothing gets exactly the plain GET the read methods issued before a cache existed (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/EntityServiceBase.cs:47,MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/AuthUIService.cs:50). Prefix invalidation rather than per-key invalidation matches how the framework's endpoints are shaped: one resource owns one route prefix, so a write knows what it invalidated without enumerating the reads. The two generation members are default interface members, so a host-supplied implementation that does not care about the in-flight race only has to implement the original four. - Where it's used: registered
TryAddScopedagainst UiReadCache byAddUIShared(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:76). Read through by EntityServiceBase<TEntityDTO, TIdentifierType>'sGetCachedAsync(EntityServiceBase.cs:253-284), which capturesGenerationbefore the GET (EntityServiceBase.cs:273) and stores through the three-argumentSet(EntityServiceBase.cs:280), and invalidated by itsInvalidateOnSuccess(EntityServiceBase.cs:285-291); cleared on sign-out and on an unrefreshable session byAuthUIService(AuthUIService.cs:190,393). - Caveats / not-in-source: nothing here is shared between users or between tabs. It is an in-memory per-scope cache, so a second browser tab on WebAssembly has its own instance and its own entries.
TokenHydrationWarmup
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth.Tokens·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/Tokens/TokenHydrationWarmup.cs:26· Level 1 · class (static, partial)
- What it is: a one-method boot-time warm-up that reads the access token once through
ITokenStorageService, so the first hydration overlaps the app's first render instead of delaying whichever API call happens to go out first. - Depends on:
ITokenStorageService(resolved, not injected, so the call is optional at the call site) andMicrosoft.Extensions.Logging.ILoggerFactory, both pulled from the host's rootIServiceProvider(TokenHydrationWarmup.cs:37,42,46). - Concept introduced, a fire-and-forget warm-up that cannot fault.
[Rubric §23, Front-End Performance]assesses avoidable delay on first render; this type exists because hydration used to run inline on whicheverAPIClientcall the user made first.[Rubric §29, Resilience & Business Continuity]applies to the swallow-and-log shape: because the call is discarded (_ = TokenHydrationWarmup.WarmAsync(...)) rather than awaited, an unhandled fault here would surface as an unobserved task exception rather than anywhere useful, so the method is written to never throw. On the anonymous path a failed warm-up is expected (there is no session to hydrate), so the failure logs atDebug, notWarning(TokenHydrationWarmup.cs:52). - Walkthrough
WarmAsync(IServiceProvider services)(TokenHydrationWarmup.cs:37) null-guardsservices(line 38).- It resolves the logger before the
try(line 41), with the comment noting that this keeps the handler resolution itself from being the thing that throws. - Inside the
try, it resolvesITokenStorageServiceand callsGetAccessTokenAsync(), discarding the result (line 45): the point is the side effect (populating the in-memory token or the single-flight hydration), not the value. - A
catch (Exception ex)(line 48,CA1031suppressed with a comment explaining the design) logs through the source-generatedLogWarmupFailed(lines 54, 59-60) and returns normally.
- Why it's built this way: a
static partial classwith a[LoggerMessage]-generated logging method keeps the warm-up allocation-light and dependency-free at the call site: it takes the root provider rather than a constructor-injected service, because it runs once at startup before any component tree exists to inject into. - Where it's used:
MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web.Client/Program.cs:92, called and discarded (_ = TokenHydrationWarmup.WarmAsync(host.Services)) right afterMmcaCultureBootstrap.SetBrowserCultureAsyncand beforehost.RunAsync(); the surrounding comment (Program.cs:87-91) records the cost it replaces, about 2.4s added to the first cold-startAPIClientcall under the old inline hydration. Covered byTokenHydrationWarmupTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Auth/TokenHydrationWarmupTests.cs:13), which exercises the success path (line 21), a storage failure (line 33) and a provider with no registered storage service (line 45).
UiReadCache
MMCA.Common.UI ·
MMCA.Common.UI.Services.Caching·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Caching/UiReadCache.cs:18· Level 1 · class (internal, sealed)
- What it is: the default IUiReadCache: an in-memory dictionary keyed by the relative request URL, guarded by a lock, with lazy TTL expiry and a longest-prefix TTL lookup.
- Depends on: IUiReadCache (the contract it implements) and
UiReadCacheOptions (the staleness policy, taken as
IOptions<UiReadCacheOptions>and unwrapped once at construction,UiReadCache.cs:27). Externals: BCLTimeProvider,System.Threading.Lock,Dictionary<string, (object, DateTimeOffset)>, andMicrosoft.Extensions.Options. - Concept introduced, lazy expiry and why there is no sweeper. The remarks state the reasoning
directly (
UiReadCache.cs:11-15): an entry past its TTL is removed when it is next read, not by a timer, because a UI cache holds tens of entries for the life of a circuit, so a sweeping timer would cost more than the entries it reclaims, and a stale entry that is never read again is never served either.[Rubric §23, Front-End Performance]assesses exactly this kind of trade: the cheapest correct expiry policy for the actual entry count.- Why a lock at all. Circuit code is not single-threaded: a periodic poll, a SignalR push handler
and a user-driven page load can all reach the same instance (lines 7-9).
[Rubric §19, State Management & Data Flow]covers the resulting ownership rule, that every dictionary touch happens under_sync. - Ordinal comparison as a correctness requirement. The comment at lines 22-24 makes the point
that two URLs differing only in case are two different requests to the server, so they must be two
different entries here. That is why the default
Dictionary<string, ...>comparer is left alone and every prefix check passesStringComparison.Ordinalexplicitly (lines 137, 170). - One generation counter for the whole dictionary.
_generationmoves under_syncon everyInvalidatePrefixandClear, and the comment at lines 29-30 states why it is one counter rather than one per prefix: the cache holds tens of entries, and a global counter also coversClear. The cost is that a write to one endpoint also drops an in-flight read of an unrelated one, which is a wasted store, never a stale one. [Rubric §14, Testability]shows in the injectedTimeProvider(line 16): TTL behavior is exercised with a fake clock rather than by sleeping, inUiReadCacheTests.
- Why a lock at all. Circuit code is not single-threaded: a periodic poll, a SignalR push handler
and a user-driven page load can all reach the same instance (lines 7-9).
- Walkthrough:
- Fields: the
Lock _sync(line 20), the entry dictionary mapping URL to a(object Value, DateTimeOffset StoredAt)tuple (line 25), the null-guarded_timeProvider(line 26), the eagerly unwrapped_options(line 27), and thelong _generationcounter (line 31). Storing the value asobjectis what lets one dictionary hold every read shape the app makes. Generation(lines 34-43) reads_generationunder the same lock that guards its increments.TryGetFresh<T>(lines 46-83) guards the URL, setsvalue = default, and short-circuits tofalsewhen_options.Enabledis off (lines 52-55). It then takes the lock and applies three exits: no entry (lines 61-64); an entry older thanResolveTtl(url), which is removed on the way out (lines 66-70); and an entry whose stored value is not aT, which is also removed, because the same URL read back as a different type means the caller changed shape and the stored value can no longer answer the question (lines 72-78). Only then is it a hit (lines 80-81).Set<T>(url, value)(lines 86-101) is a no-op when caching is disabled or the value is null (line 90), stampsGetUtcNow()outside the lock, and assigns inside it (lines 95-100).Set<T>(url, value, generation)(lines 104-126) has the same guards, then, inside the lock, returns without storing whengenerationno longer equals_generation(lines 119-122): a write invalidated the endpoint, or a sign-out cleared everything, while the read was in flight, so the value may be exactly what the write made stale (lines 117-118). Otherwise it assigns (line 124).InvalidatePrefix(lines 129-145) increments_generation(line 135) and materializes the matching keys into a list under the lock before removing them (lines 136-143), because removing while enumerating the same dictionary would throw.Clear(lines 148-155) increments_generation(line 152) and empties the dictionary under the lock.ResolveTtl(lines 163-178) is the freshness lookup: it starts fromUiReadCacheOptions.DefaultTtland scans every configured route prefix, keeping the TTL of the longest prefix the URL starts with (lines 168-175). The doc says why longest-match rather than first-match (lines 157-162): a nested route can state a stricter budget than the endpoint above it, whatever order the configuration happens to enumerate in.
- Fields: the
- Why it's built this way:
internalbecause the interface is the supported surface and the DI registration is the only supported way to get one (MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:76registers itTryAddScoped, so a host can substitute its own implementation). The clock is registered alongside it withTryAddSingleton(TimeProvider.System)(DependencyInjection.cs:71), which the comment notes is aTryAddso a host that already registered one (asAddInfrastructuredoes) keeps it and a test substitutes aFakeTimeProvider. The defaults come from the options object rather than constants: caching isEnabledby default with a 60-secondDefaultTtl(MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Settings/UiReadCacheOptions.cs:24,32), long enough to collapse the burst of identical reads a page issues while it mounts and short enough that a stale list corrects itself within one user's attention span. - Where it's used: resolved as IUiReadCache by
EntityServiceBase<TEntityDTO, TIdentifierType> and
AuthUIService; covered by
UiReadCacheTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Caching/UiReadCacheTests.cs:15), whose generation cases drop a lateSetafter an invalidation and after aClear(UiReadCacheTests.cs:156,175).
ISessionAwareTokenRefresher
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth.Tokens·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/Tokens/ISessionAwareTokenRefresher.cs:12· Level 1 · interface
- What it is: an
ITokenRefresherthat can say why it returned no token. It adds one method that reports aTokenAcquisitioninstead of a bare nullable string. - Depends on:
ITokenRefresher(base interface) andTokenAcquisition(the return type). Implemented bySameOriginProxyTokenRefresherandDirectApiTokenRefresher; consumed byWasmTokenStorageServiceandServerTokenStorageService(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Services/ServerTokenStorageService.cs). - Concept introduced, a capability interface instead of a changed contract. The base
Task<string?>answer cannot separate a definitive "no session" from a transient failure, and a caller that caches the absence of a session needs exactly that difference. Rather than break everyITokenRefresherimplementation, the richer method lives on a derived interface that callers probe for at runtime (WasmTokenStorageService.cs:125).[Rubric §1, SOLID](open for extension, closed for modification): the original contract is untouched and implementers opt in.[Rubric §12, Performance & Scalability]assesses redundant work: knowing "no session" is definitive is what lets storage skip repeat token POSTs for a short grace.
- Walkthrough: a single member,
Task<TokenAcquisition> TryAcquireAccessTokenAsync(CancellationToken cancellationToken = default)(ISessionAwareTokenRefresher.cs:17). Its documented result is "the acquired token, a definitive 'no session', or a transient failure" (line 16), and it does not throw for those cases. - Why it's built this way: see
ADR-051. A refresher
that does not implement it (for example the handoff one) keeps the previous reading, where
nullmeans "no session" (WasmTokenStorageService.cs:131-134). - Where it's used: the two refreshers above implement it, and
WasmTokenStorageService.HydrateAsynctype-tests for it (WasmTokenStorageService.cs:125). Covered byDirectApiTokenRefresherTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Auth/DirectApiTokenRefresherTests.cs).
SameOriginProxyTokenRefresher
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth.Tokens·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/Tokens/SameOriginProxyTokenRefresher.cs:16· Level 2 · class (sealed)
- What it is: the browser
ISessionAwareTokenRefresher, used by both Blazor Server and WebAssembly. It asks a JS helper to POST the same-origin/auth/session/tokenendpoint and reports what comes back as aTokenAcquisition. - Depends on:
ISessionAwareTokenRefresher(the contract),TokenAcquisitionandIJSRuntime, plus themmcaAuthSessionhelper inMMCA.Common/Source/Presentation/MMCA.Common.UI/wwwroot/mmca-auth-cookie.js:32, whosegetTokenissues thefetch(line 35). - Concept introduced, the BFF hop. The refresh token never enters this process. The browser sends
its HttpOnly cookies with
credentials:'same-origin', the UI host validates or refreshes server-side, and only the access token comes back over the wire (SameOriginProxyTokenRefresher.cs:5-10). The server half isSessionCookieEndpoints, which mapsPOST /auth/session/token(MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieEndpoints.cs:60).[Rubric §26, Front-End Security]assesses whether a long-lived credential is reachable from scripts. It is not: an XSS on this page can steal an access token that expires in minutes, not the refresh token behind it.[Rubric §7, Microservices Readiness]shows in the origin choice. The call goes to the UI host, not the API, so it stays same-origin and needs no CORS or cross-site cookie policy.
- Walkthrough: two methods.
AcquireAccessTokenAsync(cancellationToken)(SameOriginProxyTokenRefresher.cs:19) is a shim returning(await TryAcquireAccessTokenAsync(...)).AccessToken.TryAcquireAccessTokenAsync(line 23) invokesmmcaAuthSession.getTokenthrough interop (line 27) and maps a blank result toTokenAcquisition.NoSession, anything else toAcquired(line 28). The remarks (lines 11-15) record the script contract: it answersnullonly for the endpoint's 401 and throws for any other failure (a 429, a 5xx, a network error), which is what lets this method tell the two apart. The four interop-unavailable exception typesJsFetchSessionCookieSyncfilters on are caught inline (line 30) and reported asUnavailable, with the comment saying none of these says anything about the session and that the server-side cookie path covers the interop-less phases (lines 32-34). - Why it's built this way: a proxy hop instead of a direct API call is the decision recorded in
ADR-022, including the
SameSite=LaxplusSec-Fetch-Sitecheck that hardens the refresh endpoint. The three-way outcome (see ADR-051) lets token storage tell "no session" from "interop not ready or endpoint failing", so a transient failure is not remembered as anonymous; the plainstring?shim still satisfies theITokenRefreshercontract. - Where it's used: registered on both browser heads
(
MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:115andMMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web.Client/Program.cs:45); consumed indirectly byWasmTokenStorageServiceandServerTokenStorageService. Covered bySameOriginProxyTokenRefresherTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Auth/SameOriginProxyTokenRefresherTests.cs:14).
WasmTokenStorageService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth.Tokens·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/Tokens/WasmTokenStorageService.cs:22· Level 2 · class (sealed)
- What it is: the WebAssembly
ITokenStorageService. The access token lives in memory only and is hydrated on demand from the HttpOnly cookies; there is nolocalStorageanywhere in it. - Depends on:
ISessionCookieSyncandITokenRefresher(constructor parameters,WasmTokenStorageService.cs:22-24), plusJwtTokenInfofor the freshness check; optionallyISessionAwareTokenRefresher(a runtime type test inHydrateAsync, line 125); externalsSystem.Threading.LockandTimeProvider. - Concept introduced, single-flight hydration. Several callers can ask for a token in the same
instant: the
AuthDelegatingHandleron an outgoing request, theJwtAuthenticationStateProviderduring a render, andNotificationHubService's access-token provider. Without coordination each would start its own refresh, and the last one to finish would overwrite the others' token. The fix is to share one in-flightTask, and the source is explicit that the lock, not a??=, is what makes it single (lines 62-65).- Remembering "no session" for a short grace. A visitor with no session would otherwise cost one
same-origin token POST per API call. After a hydrate returns a DEFINITIVE "no session", the service
answers
nullfrom memory forAnonymousGrace, 15 seconds (line 30), checked inGetAccessTokenAsync(lines 57-60). A transient failure is never remembered, andSetTokensAsyncresets the window so a login right after anonymous browsing does not wait it out (lines 96-97). [Rubric §19, State Management & Data Flow]assesses ownership of shared per-circuit state; the in-memory token and its in-flight hydration are exactly that.[Rubric §12, Performance & Scalability]assesses redundant work: a burst of concurrent callers costs one network round trip, not one each.[Rubric §11, Security]assesses at-rest exposure. The refresh token is never held client-side, the comment at line 91 saying it lives only in the HttpOnly cookie.
- Remembering "no session" for a short grace. A visitor with no session would otherwise cost one
same-origin token POST per API call. After a hydrate returns a DEFINITIVE "no session", the service
answers
- Walkthrough:
ExpirySkew, 30 seconds (WasmTokenStorageService.cs:27), the proactive margin handed toJwtTokenInfo.IsFresh;AnonymousGrace, 15 seconds (line 30)._hydrateSync(line 32), theLock;_timeProvider(line 33), the injected clock (system clock when null);_accessToken(line 35), the in-memory token;_hydrateInFlight(line 36), the shared hydration task; and_anonymousUntil(line 37), the end of the current grace.- A second, two-argument constructor (line 45) chains to the primary one with a null clock, so code compiled against the original signature keeps binding.
GetAccessTokenAsync()(line 50) returns the cached token immediately when it is fresh (lines 52-55). If there is no token and the grace has not elapsed it returnsnullwithout any network call (lines 57-60). Otherwise it takes the lock only long enough to publish or read the in-flight task (lines 67-71), which is safe becauseHydrateAsyncreaches its firstawaitimmediately so nothing slow runs under the lock (lines 62-65). It then awaits the shared task (line 75) and, in afinally, clears_hydrateInFlightonly if it is still the same task (ReferenceEquals, line 83). That guard is the subtle half: an unguarded clear can drop a newer hydrate started after this one completed, splitting the next set of callers all over again (lines 79-80).GetRefreshTokenAsync()(line 92) returnsnullunconditionally, an honest answer rather than a stub: in the browser there is nothing to return.SetTokensAsync(accessToken, refreshToken)(line 94) resets_anonymousUntil(line 97), stores the access token in memory (line 98) and seeds the HttpOnly cookies throughISessionCookieSync.SyncAsync(line 103); the comment records that the refresh token transits JS only for that same-origin POST (lines 99-102). Afalsefrom the cookie write throwsInvalidOperationException(lines 103-105), after the in-memory token is set, soAuthUIServicereportsAuth.TokenStorageUnavailableinstead of a login that silently signs out at the first access-token expiry, when no cookie exists to refresh from.ClearTokensAsync()(line 109) nulls the field and clears the cookies (lines 111, 115). Clearing is best-effort by contract, so afalsefromClearAsyncis discarded; the comment points a caller that needs proof the session ended atIAuthUIService.RevokeAllSessionsAsync(lines 112-114).HydrateAsync()(line 118) branches on the refresher's capability. When it is anISessionAwareTokenRefresher(line 125) it callsTryAcquireAccessTokenAsync, storesAccessTokenand readsIsUnavailable(lines 127-129); otherwise it falls back toITokenRefresher.AcquireAccessTokenAsync, where anullmeans "no session" (line 133). Only a null token that is not unavailable starts the grace (lines 136-139), so a 429, a 5xx or unavailable interop never makes a signed-in user look anonymous. SeeTokenAcquisition.
- Why it's built this way: the class doc records that it was hoisted out of the app WASM clients
because it carries no app-specific state, and names its Blazor Server sibling
ServerTokenStorageServicein MMCA.Common.UI.Web. The two share the skew constant, theLock, and the same single-flight shape; the server one adds anHttpContextbranch for the prerender pass. The cookie-only storage model is ADR-022. - Where it's used: registered on the WASM clients,
AddScoped<ITokenStorageService, WasmTokenStorageService>()(MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web.Client/Program.cs:45,MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web.Client/Program.cs:48). Covered byWasmTokenStorageServiceTests, which drives the concurrency path directly and pins the failed cookie write as a throw (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Auth/WasmTokenStorageServiceTests.cs:16,237).
DirectApiTokenRefresher
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth.Tokens·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/Tokens/DirectApiTokenRefresher.cs:35· Level 2 · class (sealed)
- What it is: the MAUI
ISessionAwareTokenRefresher(and so anITokenRefresher). It reads the token pair out of OS SecureStorage, exchanges it against the API's cross-originauth/refreshendpoint, and writes the rotated pair back, reporting the outcome as aTokenAcquisition. - Depends on:
ISecureTokenStore(deliberately, notITokenStorageService),AuthDelegatingHandler(for itsSkipBearerrequest option),RefreshTokenRequestandAuthenticationResponsefromMMCA.Common.Shared.Auth; externalsIHttpClientFactoryandSystem.Net.Http.Json. - Concept introduced, direct token handling where there is no DOM. The class doc justifies the
choice: MAUI has no browser and thus no XSS surface, so holding a refresh token client-side and
posting it is acceptable there in a way it is not in a browser
(
DirectApiTokenRefresher.cs:9-11). This is the counterpart toSameOriginProxyTokenRefresher, and reading the two together is the clearest statement of the framework's per-platform threat model.[Rubric §11, Security]assesses per-platform credential handling, exactly as above.[Rubric §1, SOLID], dependency direction: the second doc paragraph is unusually explicit (lines 12-18). Every operation it performs is a raw read or write, so it takes the raw store; takingITokenStorageServiceinstead would close the loop and let a refresh re-enter the acquisition that started it.- The same cycle through the HTTP pipeline. The third doc paragraph (lines 19-24) names the
indirect route back into that loop: the
APIClientcarriesAuthDelegatingHandler, which reads the storage service for a bearer. The refresh POST therefore setsAuthDelegatingHandler.SkipBearer(line 61), so the anonymous refresh call never reaches the storage instance that is awaiting it. The handler honors the option by passing the request through untouched (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/AuthDelegatingHandler.cs:22,29). - A 409 is transient, never "no session". The fourth doc paragraph (lines 26-33) explains
Auth.RefreshSuperseded: the presented token was rotated by another request inside the server's reuse grace. Clearing the pair on that answer would let a stolen-and-rotated token live for the full refresh lifetime, so the stored pair is left untouched and the next attempt presents it again.
- Walkthrough:
AcquireAccessTokenAsync(cancellationToken)(DirectApiTokenRefresher.cs:42) is an expression-bodied shim overTryAcquireAccessTokenAsync(line 46) that returns only.AccessToken, which keeps the plainITokenRefreshercontract intact. TheTrymethod is a straight line of early returns, each producing aTokenAcquisitionrather than an exception:- read both tokens from the store (lines 48-49), and return
NoSessionwhen either is blank (lines 51-54), so a never-logged-in device costs one storage read and no network call; - create the named
"APIClient"(ApiClientName, line 39), build anHttpRequestMessagePOSTing aRefreshTokenRequestcarrying both tokens asJsonContentto the relativeauth/refresh(lines 56-60), mark itSkipBearer(line 61), and send it (line 62); - on
HttpStatusCode.Conflict(lines 64-67) delegate toReadSupersededOutcomeAsync(line 88), which re-reads the store: if another refresh in this process already stored a different non-blank pair it returnsAcquiredwith that access token, otherwiseUnavailablewith the pair left as it was (lines 90-97); - return
NoSessionon any other non-success status (lines 69-72), which is how a revoked or reused refresh token arrives; - deserialize an
AuthenticationResponseand returnNoSessionwhen the access token came back blank (lines 74-78); - persist the rotated pair through
ISecureTokenStore.SetTokensAsyncand returnTokenAcquisition.Acquiredwith the new access token (lines 80-81).
- read both tokens from the store (lines 48-49), and return
- Why it's built this way: the rotation-on-refresh shape it participates in is
ADR-097, which made
refresh sessions hashed, rotating and per device: writing the returned pair back is not an
optimization, it is required, because the old refresh token is dead after the exchange. An explicit
HttpRequestMessagerather thanPostAsJsonAsyncis what gives the call anOptionsbag to carrySkipBearer. Theusing var httpClient(line 56) and the relativeUriboth lean on the named client configured once inAddUIShared, which also attaches the delegating handler (MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:124,146). - Where it's used: registered on the MAUI heads,
AddScoped<ITokenRefresher, DirectApiTokenRefresher>()(MMCA.Store/Source/Hosts/UI/MMCA.Store.UI/MauiProgram.cs:98,MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/MauiProgram.cs:166). Covered byDirectApiTokenRefresherTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Auth/DirectApiTokenRefresherTests.cs:18), which stubs the store and the HTTP handler (lines 25-36) and asserts theSkipBearermark on the refresh POST (line 62).
CultureDelegatingHandler
MMCA.Common.UI ·
MMCA.Common.UI.Services.Culture·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Culture/CultureDelegatingHandler.cs:13· Level 0 · class (sealed)
- What it is: a one-method
DelegatingHandlerthat stamps the active UI culture onto every outgoing API call as anAccept-Languageheader, so validation and error text come back from the backend in the language the user selected. - Depends on: no first-party types. Externals:
System.Net.Http.DelegatingHandler,System.Globalization.CultureInfo, andSystem.Net.Http.Headers.StringWithQualityHeaderValue. It rides the same named"APIClient"pipeline as AuthDelegatingHandler. - Concept introduced, culture as a transport header rather than only a cookie.
[Rubric §27, Internationalization]assesses whether locale is carried end to end instead of being applied only at the rendering edge; this handler is the piece that closes that loop for server-produced strings. The class comment (CultureDelegatingHandler.cs:7-11) states the reason plainly: the cross-origin Gateway does not carry the ASP.NET culture cookie through to the services, so a cookie-only design would render the page in Spanish while the API answered in English.[Rubric §6, CQRS & Event-Driven Design]also applies, because this is a concern every service call needs and no service call implements: it is attached once in the HttpClient pipeline instead of at each call site. - Walkthrough
- The only member is the
SendAsyncoverride (CultureDelegatingHandler.cs:16). - It reads
CultureInfo.CurrentUICulture.Name(CultureDelegatingHandler.cs:20) and does nothing when that is blank (CultureDelegatingHandler.cs:21), so an unresolved culture sends no header rather than an empty one. - When there is a value it calls
AcceptLanguage.Clear()beforeAdd(...)(CultureDelegatingHandler.cs:23-24); the clear matters because a retried request object would otherwise accumulate a second language entry. - It returns
base.SendAsync(request, cancellationToken)directly (CultureDelegatingHandler.cs:27) rather than awaiting it, so the handler adds no async state machine to the hot path.
- The only member is the
- Why it's built this way: ADR-027
makes multi-locale a whole-stack concern. Registering the behavior as a message handler means the
culture travels on calls made by code that has never heard of localization. It is registered
transient in DependencyInjection
(
MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:115) and appended to the"APIClient"pipeline after the auth handler (DependencyInjection.cs:146-147). - Where it's used: every request through the
"APIClient"named client, which is every call made by EntityServiceBase<TEntityDTO, TIdentifierType>, ChildEntityServiceBase, ApiUserPreferenceReader and ApiUserPreferenceWriter. - Caveats / not-in-source: it reads whatever ambient UI culture the head has already established. On a Blazor WebAssembly head that is set by MmcaCultureBootstrap before the host runs; the handler itself makes no attempt to resolve or validate a culture.
ICultureApplier
MMCA.Common.UI ·
MMCA.Common.UI.Services.Culture·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Culture/ICultureApplier.cs:14· Level 0 · interface
- What it is: the one-method contract for "switch the language and put the user back where they were", written as an abstraction because the mechanism differs per head.
- Depends on: nothing. Implemented by EndpointCultureApplier (the Blazor Web default) and by MauiCultureApplier in the device-capability layer.
- Concept introduced, a terminal call.
[Rubric §18, UI Architecture]assesses whether host-specific mechanics are hidden behind contracts the components can share; this interface is the clearest example in the UI package. The doc comment (ICultureApplier.cs:3-13) spells out both halves of the contract: a Blazor Web head round-trips the server/culture/setendpoint so the cookie, the SSR prerender and the WASM runtime all agree, while a MAUI Blazor Hybrid head has no ASP.NET pipeline and switches the process culture in place. Because each implementation owns landing the user back on the return path (a redirect on the web, a WebView reload on a hybrid head), callers must treatApplyAsyncas terminal and do no navigation of their own.[Rubric §25, Navigation & Information Architecture]applies for the same reason: navigation ownership is part of the contract rather than an afterthought at each call site. - Walkthrough
ApplyAsync(string culture, string returnPath, CancellationToken cancellationToken = default)(ICultureApplier.cs:27) is the whole surface.- Two documented behaviors belong to the contract rather than to any one implementation: a culture
outside
SupportedCultures.Allis ignored by the underlying mechanism rather than throwing (ICultureApplier.cs:19-22), and an emptyreturnPathfalls back to"/"(ICultureApplier.cs:23-25).
- Why it's built this way:
ADR-027. Hard-coding the endpoint
navigation into the culture switcher component would have made that component unusable on MAUI,
where the URL matches no route and the Blazor
Routerrenders the not-found page. The interface lets one shared component serve both heads. - Where it's used: injected by the shared
CultureSwitchercomponent, which persists the choice first and then treats the applier call as the last thing it does (MMCA.Common/Source/Presentation/MMCA.Common.UI/Globalization/CultureSwitcher.razor:6andCultureSwitcher.razor:44-48), and by the login page when reconciling a returning user's stored culture (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Login.razor:15andLogin.razor:239-244).
IPublicLinkBuilder
MMCA.Common.UI ·
MMCA.Common.UI.Services.Navigation·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Navigation/IPublicLinkBuilder.cs:9· Level 0 · interface
- What it is: a one-method abstraction that turns an app-relative path into an absolute, publicly
shareable URL (
IPublicLinkBuilder.cs:9-13). It exists so share sheets, copy-link buttons and QR payloads produce a URL that still works once it leaves the app. - Depends on: nothing first-party. BCL
Uriandstring. Implemented by NavigationPublicLinkBuilder in this package and by MauiPublicLinkBuilder on the hybrid head. - Concept introduced, head-agnostic absolute link building.
[Rubric §18, UI Architecture]assesses whether shared components stay host-agnostic instead of branching on the host they run in, and[Rubric §25, Navigation & Information Architecture]assesses whether outbound links are built from one authority rather than string-concatenated per call site. The doc comment (IPublicLinkBuilder.cs:3-8) states the problem exactly: web heads can derive a shareable origin from the browser, but the MAUI head cannot, because its internal origin is the WebView's virtual host. Encoding that virtual origin into a QR code or a shared link would produce a URL nobody outside the app can open. One interface with two implementations moves the head-specific knowledge to the composition root and lets the pages stay identical on every head. - Walkthrough
- A single member,
Uri BuildAbsolute(string relativePath)(IPublicLinkBuilder.cs:13). It returns aUrirather than astring, so callers that need text do theToString()themselves, and the doc comment gives/sessions/42as the shape of the argument (IPublicLinkBuilder.cs:11). - The default binding resolves the path against
NavigationManager.BaseUri(MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Navigation/NavigationPublicLinkBuilder.cs:25), after rejecting a blank path (NavigationPublicLinkBuilder.cs:23). - The hybrid binding resolves against the
PublicSite:BaseUrlkey pinned in the head's embedded configuration (MMCA.Common/Source/Presentation/MMCA.Common.UI.Maui/Services/MauiPublicLinkBuilder.cs:17andMauiPublicLinkBuilder.cs:28-32), and throwsInvalidOperationExceptionat construction when the key is missing, so a misconfigured head fails at startup instead of shipping unusable links.
- A single member,
- Why it's built this way: the default is registered with
TryAddScoped(DependencyInjection.cs:149) and the comment above it (DependencyInjection.cs:145-148) records the override rule: the hybrid head callsAddCommonMauiPublicLinkBuilder()afterAddUIShared(MMCA.Common/Source/Presentation/MMCA.Common.UI.Maui/DependencyInjection.cs:140-141, invoked atMMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/MauiProgram.cs:145) and the last registration wins. That is the same head-override composition convention ADR-042 establishes for the device capability layer. - Where it's used: the shared
SharePageButtoncomponent (MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/Sharing/SharePageButton.razor:4andSharePageButton.razor:43), the sharedQrCodeButtoncomponent (QrCodeButton.razor:1andQrCodeButton.razor:79), and app pages such as ADC's speaker QR page (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speakers/SpeakerQr.razor.cs:26andSpeakerQr.razor.cs:62). A bUnit test pins the default registration to the browser-origin builder (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Navigation/NavigationPublicLinkBuilderTests.cs:59).
BackNavigationResult
MMCA.Common.UI ·
MMCA.Common.UI.Services.Navigation·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Navigation/MauiBackNavigationBridge.cs:19· Level 0 · record (sealed)
- What it is: the outcome of a hardware-back or WebView-back attempt routed through MauiBackNavigationBridge: whether the WebView consumed the gesture, and whether the WebView is sitting at the root of its history stack.
- Depends on: nothing first-party. It is a two-field positional record produced and consumed by MauiBackNavigationBridge.
- Concept introduced, the interop return contract. This record is also the wire shape of a single
JS interop call:
nav-interop.js'stryGoBack()returns an object that deserializes straight into it (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Navigation/MauiBackNavigationBridge.cs:48), so the C# type and the JS return value are one contract. - Walkthrough:
public sealed record BackNavigationResult(bool Handled, bool AtRoot)(MauiBackNavigationBridge.cs:19).Handledistruewhen the WebView's history stack contained a previous entry andhistory.back()fired (MauiBackNavigationBridge.cs:9-13);AtRootistruewhen no previous entry exists, and the doc comment records that MAUI hosts typically exit the app on Android when that is reported (MauiBackNavigationBridge.cs:14-18). - Why it's built this way: a
sealed recordbuys structural equality and positional deconstruction for free, and it is the smallest thing that can carry the two facts the native host needs. Modeling the answer as data (rather than throwing, or mutating shared state) keeps the interop call pure and trivially testable. - Where it's used: returned by
MauiBackNavigationBridge
.HandleBackPressedAsync; consumed by MAUI hostContentPage.OnBackButtonPressedhandlers.
ReturnUrlProtector
MMCA.Common.UI ·
MMCA.Common.UI.Services.Navigation·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Navigation/ReturnUrlProtector.cs:9· Level 0 · class (static)
- What it is: a pure sanitizer for
returnUrlquery parameters: it accepts only same-origin relative paths and replaces anything else with a safe fallback, closing the open-redirect hole in post-login and post-action redirects. - Depends on: nothing first-party;
System.Uri(BCL) supplies the final relative-URI parse guard. - Concept introduced, open-redirect defense.
[Rubric §26, Front-End Security]assesses whether user-controlled navigation targets are validated before use. An open redirect lets an attacker craft/login?returnUrl=https://evil.comso the victim lands on an attacker site after authenticating, a classic phishing amplifier.Sanitizerejects every off-host form rather than trying to enumerate attacks, and the ordered guards read as a documented threat model. - Walkthrough:
Sanitize(string? candidate, string fallback = "/")(ReturnUrlProtector.cs:18) runs a sequence of cheap, regex-free checks (a regex here would invite ReDoS), each returningfallbackon failure:- null or empty (lines 20-23);
- must start with
/, which rules out scheme-prefixed absolutes such ashttp://andjavascript:(lines 25-30); - the second character must not be
/or\, which browsers read as the start of an authority component and would send the user off-host (lines 32-37); - no backslash anywhere, since some browsers normalize
\to/(the source names"/\\evil.com"becoming//evil.comin Chrome, lines 39-44); - no control characters, which are header-injection, response-splitting and cookie-smuggling vectors (lines 46-51);
- and finally it must parse as a well-formed relative URI (
Uri.TryCreate(..., UriKind.Relative), lines 53-57). Only a candidate that survives all six is returned unchanged (line 59).
- Why it's built this way: a static pure function whose only input is the candidate is trivially unit-testable across every attack vector, and calling it centrally means no page hand-rolls its own redirect validation.
- Where it's used: login and post-authentication redirects sanitize the
returnUrlthey read from the query string; NavigationHistoryService.GoBackAsyncalso runs its fallback path through it (NavigationHistoryService.cs:82), so even the "safe" branch cannot be turned into a redirect vector.
EndpointCultureApplier
MMCA.Common.UI ·
MMCA.Common.UI.Services.Culture·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Culture/EndpointCultureApplier.cs:18· Level 1 · class (sealed)
- What it is: the Blazor Web implementation of ICultureApplier. It
force-navigates to the server's
GET /culture/setendpoint, which writes the culture cookie and redirects the user back to where they were. - Depends on: ICultureApplier (implemented) and
Microsoft.AspNetCore.Components.NavigationManager. It pairs with the server endpoint mapped byMapCultureEndpoint()(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationExtensions.cs:102, theMapGet("/culture/set", ...)atWebApplicationExtensions.cs:120) and with MmcaCultureBootstrap on the WASM side. - Concept introduced, why the full page reload is deliberate.
[Rubric §27, Internationalization]assesses whether a locale switch is coherent across every rendering path; the class comment (EndpointCultureApplier.cs:8-10) says the force-load is load-bearing, because the server has to re-render SSR under the new cookie and the WASM runtime has to re-read it on startup, which keeps prerender and hydration on the same culture. A soft, client-only switch would leave the two disagreeing and show a locale flash on the next full load. - Walkthrough
ApplyAsync(EndpointCultureApplier.cs:21) rejects a null or whitespace culture up front withArgumentException.ThrowIfNullOrWhiteSpace(EndpointCultureApplier.cs:23).- It falls back to
"/"for an empty return path (EndpointCultureApplier.cs:25) and builds the URL withUri.EscapeDataStringon both values (EndpointCultureApplier.cs:26), so a return path containing a query string survives round-tripping. - It then calls
navigation.NavigateTo(url, forceLoad: true)(EndpointCultureApplier.cs:30) and returnsTask.CompletedTask(EndpointCultureApplier.cs:31): the method is synchronous in substance andTask-shaped only because the interface must also fit asynchronous heads. - The inline comment (
EndpointCultureApplier.cs:28-29) notes that validating the culture is the endpoint's job: an unsupported value lands the user back on the same page unchanged rather than failing.
- Why it's built this way:
ADR-027. The class comment
(
EndpointCultureApplier.cs:11-15) also records the boundary of its validity: a head with no ASP.NET pipeline would route/culture/setthrough the BlazorRouter, match no page and render the not-found page, which is exactly why MAUI heads register their own applier afterAddUIShared. - Where it's used: registered as the default with
TryAddScoped<ICultureApplier, EndpointCultureApplier>()(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:186), with the comment above it (MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:182-185) recording that a hybrid head overrides it afterwards. The MAUI replacement is MauiCultureApplier.
MmcaCultureBootstrap
MMCA.Common.UI ·
MMCA.Common.UI.Services.Culture·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Culture/MmcaCultureBootstrap.cs:14· Level 1 · class (static)
- What it is: the Blazor WebAssembly culture bootstrap. It reads the same ASP.NET culture cookie the server used for SSR prerender and sets the WASM runtime's default thread cultures before the host starts running.
- Depends on: SupportedCultures from the
Shared layer and the
culture.jsmodule in the package'swwwroot. Externals:IJSRuntimeandCultureInfo. - Concept introduced, closing the prerender/hydration culture gap.
[Rubric §27, Internationalization]assesses whether every rendering path resolves the same locale; the class comment (MmcaCultureBootstrap.cs:7-13) states the outcome this buys: the interactive client renders in the same language the server prerendered, with no locale flash and no prerender/hydration mismatch.[Rubric §23, Front-End Performance]applies too, because the alternative (letting the client discover the culture after first render) costs a visible re-render of the whole page. - Walkthrough
- Two overloads of
SetBrowserCultureAsync. The one-argument form (SetBrowserCultureAsync(IJSRuntime jsRuntime),MmcaCultureBootstrap.cs:25) forwards withallowPseudoLocale: false, so the pseudo locale is never accepted through it. The real work is inSetBrowserCultureAsync(IJSRuntime jsRuntime, bool allowPseudoLocale)(MmcaCultureBootstrap.cs:41), null-guarded atMmcaCultureBootstrap.cs:43. A Development host passesbuilder.HostEnvironment.IsDevelopment()as the flag, because the server accepts the pseudo locale only in Development (MmcaCultureBootstrap.cs:28-40). - It imports
./_content/MMCA.Common.UI/culture.jsunder anawait using(MmcaCultureBootstrap.cs:45-46), so the module reference is released as soon as the one call is done: unlike the long-lived services, this runs once at startup and has no reason to hold it. - It calls
getCulture(MmcaCultureBootstrap.cs:47), whose JS side parses the.AspNetCore.Culturecookie'suic=segment and returns null when the cookie is absent or unparseable (MMCA.Common/Source/Presentation/MMCA.Common.UI/wwwroot/culture.js:4). - The returned value is accepted when
SupportedCultures.IsSupportedsays so, or whenallowPseudoLocaleis true andSupportedCultures.IsPseudoLocalematches; anything else falls back toSupportedCultures.Default(MmcaCultureBootstrap.cs:49-51). The predicate is atMMCA.Common/Source/Core/MMCA.Common.Shared/Globalization/SupportedCultures.cs:35, the pseudo-locale check atSupportedCultures.cs:76, the"qps-Ploc"constant atSupportedCultures.cs:28, the allowlist atSupportedCultures.cs:18, and the"en-US"default atSupportedCultures.cs:12. - It then assigns only
CultureInfo.DefaultThreadCurrentCultureandCultureInfo.DefaultThreadCurrentUICulture(MmcaCultureBootstrap.cs:53-54), neverCurrentCulture/CurrentUICulturedirectly: setting the defaults makes every subsequently created thread inherit the culture, which is what a later switch needs in order to take effect.
- Two overloads of
- Why it's built this way:
ADR-027. The cookie is deliberately
non-HttpOnly for exactly this reader, a decision recorded in the suppression comment on the server
side (
MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationExtensions.cs:111, written atWebApplicationExtensions.cs:120), which namesMmcaCultureBootstrap.SetBrowserCultureAsyncas the consumer. The doc comment (MmcaCultureBootstrap.cs:11-12) also pins the call ordering: it must run afterbuilder.Build()and beforehost.RunAsync(). - Where it's used: both Blazor Web clients call the two-argument overload exactly as documented,
passing
builder.HostEnvironment.IsDevelopment()so hydration keeps the pseudo locale the server prerendered:MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web.Client/Program.cs:83andMMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web.Client/Program.cs:71. Its MAUI counterpart is MauiCultureInitializer.
NavigationPublicLinkBuilder
MMCA.Common.UI ·
MMCA.Common.UI.Services.Navigation·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Navigation/NavigationPublicLinkBuilder.cs:11· Level 1 · class (sealed)
- What it is: the default IPublicLinkBuilder. It turns an app-relative route
such as
sessions/42into an absolute URL by resolving it against the origin the browser is currently served from, which is what a share sheet, a copy-link button or a QR payload needs. - Depends on: IPublicLinkBuilder (the contract it implements,
NavigationPublicLinkBuilder.cs:11);Microsoft.AspNetCore.Components.NavigationManager(ASP.NET Core Blazor,NavigationPublicLinkBuilder.cs:1,13) for the origin. Nothing else: no HTTP, no configuration, no JS interop. - Concept introduced, the origin a link is built from is a per-head decision, not a per-page one.
[Rubric §25, Navigation, Routing & Information Architecture]assesses whether routes and the URLs built from them are modelled once rather than reconstructed ad hoc at each call site. A page that wants to share itself has two candidate origins available, and only one of them is right: the in-process origin the component is rendering under, and the public web origin a recipient can open. On the Server and WebAssembly heads those coincide, soNavigationManager.BaseUriis the correct answer and this class is a two-line adapter over it. On the MAUI Blazor Hybrid head they do not: the WebView serves the app from an internal virtual host, so an absolute URL built fromBaseUrithere would be unopenable anywhere else (the class comment records exactly this at lines 5-10). Hoisting the decision behind an interface is what lets the sharedSharePageButtonandQrCodeButtonstay head-agnostic (MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/Sharing/SharePageButton.razor:4,MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/Sharing/QrCodeButton.razor:1).[Rubric §18, UI Architecture & Component Design]reads the same choice from the component side: the shared components inject a contract, never aNavigationManager. - Walkthrough
- One readonly field,
_navigationManager(line 13), assigned by an expression-bodied constructor (lines 17-18). The class issealedand holds no other state, so a scoped instance costs a reference. BuildAbsolute(string relativePath)(line 21) rejects a blank path outright withArgumentException.ThrowIfNullOrWhiteSpace(line 23): an empty share link is a caller bug, not a condition to render, and this is the one place cheap enough to catch it.- The build itself is one expression (line 25):
new Uri(new Uri(_navigationManager.BaseUri, UriKind.Absolute), relativePath). The innerUriforces the base to be parsed as absolute, and the outer resolution applies standard URI reference resolution to the path. - The behavior callers rely on is pinned rather than assumed:
NavigationPublicLinkBuilderTests
asserts that
"/sessions/42"and"sessions/42"both resolve tohttp://localhost/sessions/42against the bUnit origin (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Navigation/NavigationPublicLinkBuilderTests.cs:21-25), that a query string survives (:27-29), and that a blank path throws (:31-41).
- One readonly field,
- Why it's built this way:
AddUISharedregisters this implementation withTryAddScoped(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:196), so every head gets a working builder without opting in, and the one head that must differ replaces it afterwards:AddCommonMauiPublicLinkBuilder()registers MauiPublicLinkBuilder over the configured public site URL (MMCA.Common/Source/Presentation/MMCA.Common.UI.Maui/DependencyInjection.cs:141). The registration shape is itself asserted, implementation type and lifetime, so a refactor that changes the default is a red test rather than a silently wrong share link (NavigationPublicLinkBuilderTests.cs:43-62). - Where it's used: injected by the framework's share affordances,
SharePageButtonandQrCodeButton, and by ADC's SpeakerQr page, which encodes the absolute public URL into the badge QR rather than the WebView origin (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speakers/SpeakerQr.razor.cs:26). The bUnit harnesses in both repos register it explicitly so component tests exercise the real builder (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/BunitTestBase.cs:42,MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.UI.Tests/BunitTestBase.cs:35). - Caveats:
BuildAbsoluteperforms no allow-list check onrelativePath, and nothing in the class restricts the result to the app's own origin, so a path value that came from user input should be sanitized upstream.
MauiBackNavigationBridge
MMCA.Common.UI ·
MMCA.Common.UI.Services.Navigation·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Navigation/MauiBackNavigationBridge.cs:28· Level 1 · class (static)
- What it is: a static bridge that routes a native MAUI back gesture (Android hardware back, iOS swipe) into the BlazorWebView's internal history stack, so pressing back inside a hybrid app behaves like a web back rather than tearing down the page.
- Depends on: BackNavigationResult (its return type);
Microsoft.JSInterop(IJSRuntime,IJSObjectReference,JSDisconnectedException,JSException) and thenav-interop.jsmodule shipped as static web assets of this package. - Concept introduced, MAUI-to-WebView interop.
[Rubric §22, Responsive & Cross-Browser]extends to hybrid hosts here: the same Blazor UI runs inside a MAUI WebView, and native chrome events must be reconciled with web navigation. The class doc states the required call site precisely, fromContentPage.OnBackButtonPressedviaBlazorWebView.TryDispatchAsync, so the call runs on the renderer thread with access to the WebView'sIJSRuntime(MauiBackNavigationBridge.cs:21-27). - Walkthrough:
HandleBackPressedAsync(IJSRuntime js)(line 38) null-checks the runtime (ArgumentNullException.ThrowIfNull, line 40), dynamically imports./_content/MMCA.Common.UI/nav-interop.js(ModulePath, line 30) into anawait usingmodule reference (line 47) and invokes itstryGoBack()helper, deserializing the answer into a BackNavigationResult (lines 48-49). The module reference is disposed on every call: the class is static, so nothing else owns it, and re-importing on each back press without disposing would leak one JS object reference per press (comment at lines 44-46). Three interop failure modes are caught explicitly and collapse to the same safe valuenew BackNavigationResult(Handled: false, AtRoot: true):InvalidOperationExceptionwhen Blazor is not yet hydrated (lines 51-55),JSDisconnectedException(lines 56-59, which also covers a dispose on a torn-down circuit), andJSException(lines 60-63). A not-yet-ready WebView therefore reports "at root, not handled" and the host falls back to its default back behavior. - Why it's built this way: a static helper with no state fits a one-shot interop call, and returning a data record instead of throwing keeps the native handler branch-free. Because it holds no state, it also cannot cache the imported module, which is why each call owns and disposes its own reference. Collapsing the three JS exception types into one safe default means an unhydrated or disconnected circuit never crashes the native back button.
- Where it's used: the MAUI host base page in
MMCA.Common.UI.Maui(MMCA.Common/Source/Presentation/MMCA.Common.UI.Maui/MainPageBase.cs:77) calls it from itsOnBackButtonPressedoverride (MainPageBase.cs:38); the returned BackNavigationResult tells the host whether to exit the app. - Caveats / not-in-source: the
nav-interop.jstryGoBack()implementation and theMainPageBasedispatch logic live outside this unit; only the C# side of the bridge is visible here.
NavigationHistoryService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Navigation·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Navigation/NavigationHistoryService.cs:12· Level 1 · class (sealed)
- What it is: a per-circuit service that bridges Blazor's
NavigationManagerwith the browser history API, so a "Back" button can perform a realhistory.back()when an in-history entry exists and fall back to an explicit route otherwise. - Depends on: ReturnUrlProtector (sanitizes the fallback) and
LazyJsModule (the shared single-flight JS module importer, held as
_module,NavigationHistoryService.cs:16);NavigationManagerandIJSRuntimearrive through the primary constructor (line 12). ImplementsIAsyncDisposable. - Concept introduced, honoring real browser history.
[Rubric §25, Navigation, Routing & Information Architecture]assesses predictable, source-aware navigation. A hard-coded "back to list" link ignores where the user actually came from; this service instead asks the browser whether a previous entry exists and navigates to it, falling back to a route only when it does not (NavigationHistoryService.cs:50-54).[Rubric §26, Front-End Security]applies to the fallback: it is sanitized rather than trusted (line 82). - Walkthrough:
ModulePath(line 14) names./_content/MMCA.Common.UI/nav-interop.js, the same module the MAUI bridge imports;_modulewraps it in a LazyJsModule (line 16) so concurrent callers share one import.CanGoBackAsync()(lines 23-48) resolves the module, returnsfalsewhen it is unavailable (lines 28-31), then invokeshistoryLengthand reportslength > 1(lines 33-34). Interop failures during SSR prerender or after a disconnect are swallowed asfalse(InvalidOperationException,JSDisconnectedException,JSException, lines 36-47).GoBackAsync(string fallback = "/")(lines 55-83) callshistoryBackwhen history is available and returns (lines 57-66); every interop failure falls through the three catch blocks (lines 68-79) to the single exit at line 82,navigation.NavigateTo(ReturnUrlProtector.Sanitize(fallback)). The method therefore always ends in a navigation.GetModuleAsync()(lines 85-99) delegates to_module.GetOrImportAsync()and turns a prerender or disconnect failure intonullrather than an exception.DisposeAsync()(line 102) forwards to the module wrapper, releasing the imported JS reference with the circuit.
- Why it's built this way: sealed and scoped per circuit, because the cached JS module reference
and history semantics are per-connection. Delegating the import to LazyJsModule
removes a real bug class: an unguarded
_module ??= await import(...)lets two concurrent callers each start an import and leaks the loser's reference (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/LazyJsModule.cs:5-13). Routing the fallback through ReturnUrlProtector means even the safe branch cannot be turned into a redirect vector, and the layered exception handling guaranteesGoBackAsyncnever strands the user. - Where it's used: injected into detail-page "Back" buttons; the same
nav-interop.jsprimitives back the MAUI hardware-back path through MauiBackNavigationBridge.
ViewerTimeZone
MMCA.Common.UI ·
MMCA.Common.UI.Services.Culture·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Culture/ViewerTimeZone.cs:18· Level 1 · class (sealed)
- What it is: a scoped service that learns the viewer's browser time zone once and converts the UTC
instants the API serializes onto the clock the person actually reads. Until the zone is known it
reports UTC (
ViewerTimeZone.cs:32). - Depends on: LazyJsModule (held as
_module, built over the host'sIJSRuntimein the constructor,ViewerTimeZone.cs:26) and thetime-zone.jsmodule named atViewerTimeZone.cs:20. ImplementsIAsyncDisposable. - Concept introduced, rendering instants on the viewer's clock.
[Rubric §27, Internationalization]assesses whether dates are shown the way the reader expects, not the way the server stores them. A server-sideToLocalTime()would use the server's zone; this service asks the browser instead. - Walkthrough
Zone(ViewerTimeZone.cs:32) defaults toTimeZoneInfo.Utc;IsResolved(ViewerTimeZone.cs:38) stays false while JS interop is unavailable, so a later call can retry.EnsureResolvedAsync(ViewerTimeZone.cs:49) returns early when already resolved, then imports the module and invokesgetTimeZone(ViewerTimeZone.cs:59-60). JS side:MMCA.Common/Source/Presentation/MMCA.Common.UI/wwwroot/time-zone.js:6returns theIntl.DateTimeFormatzone id, or null when the browser cannot say.- Failure handling is split by whether a retry could help.
InvalidOperationException(SSR prerender, no interop yet),JSDisconnectedExceptionandOperationCanceledExceptionreturn false without marking resolved (ViewerTimeZone.cs:62-74); aJSExceptionmeans the browser answered with an error, so the zone id becomes null and resolution is final (ViewerTimeZone.cs:76-80). - On success it sets
IsResolved, resolves the id through the privateResolveZone, and returns whether the zone id changed (ViewerTimeZone.cs:82-86), so the caller knows whether to re-render.ResolveZone(ViewerTimeZone.cs:122) falls back to UTC for a missing or unknown id. ToViewerTime(ViewerTimeZone.cs:97) readsUtcandUnspecifiedkinds as UTC (a deserialized value often arrives unspecified) and normalizesLocalfirst, then callsTimeZoneInfo.ConvertTimeFromUtc.Format(ViewerTimeZone.cs:112) formats that result withCultureInfo.CurrentCulture, default format"g".DisposeAsync(ViewerTimeZone.cs:116) forwards to the module wrapper.
- Why it's built this way: ADR-027
treats locale as a per-viewer concern. Returning a "changed" boolean from
EnsureResolvedAsynckeeps the service free of render coupling: the page decides whether to callStateHasChanged. Swallowing prerender interop failures lets the same component render on the server (UTC) and then correct itself after hydration. - Where it's used: registered with
TryAddScoped<ViewerTimeZone>()(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:190) and injected byNotificationList.razor.cs:26,NotificationInbox.razor.cs:36andSessions.razor.cs:34underMMCA.Common.UI/Pages/, plus ADC pages such asMMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/Points/MyPoints.razor.cs. Covered byMMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Culture/ViewerTimeZoneTests.cs.
ChannelReferenceCounter
MMCA.Common.UI ·
MMCA.Common.UI.Services.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/ChannelReferenceCounter.cs:16· Level 0 · class (internal, sealed)
- What it is: a small self-synchronized counter that tracks how many outstanding joins the circuit holds for each live-channel key, so NotificationHubService tells the SignalR server to join a group on the first join and to leave it only on the last matching leave.
- Depends on: nothing first-party. It is a
System.Threading.Lockplus aDictionary<string, int>(BCL). It is owned as a private field by NotificationHubService (NotificationHubService.cs:62), and sits beside (not inside) the separate handler bookkeeping that ChannelSubscription unwinds. - Concept introduced, reference-counted group membership.
[Rubric §19, State Management & Data Flow]assesses how a shared per-circuit resource is owned when more than one component holds it at once. A live channel is exactly that resource: an invisible layout listener and a page can both be watchingevent:1. The class remarks state why a set is the wrong structure (ChannelReferenceCounter.cs:5-10): with set semantics the first leaver removes the only entry and cuts the channel off for every other subscriber still holding it. Counting joins per key turns membership into two edges, 0 to 1 and 1 to 0, and only those two moments need to reach the server.[Rubric §29, Resilience & Business Continuity]applies as well, becauseSnapshot()is the replay list the hub service re-joins after an automatic reconnect.[Rubric §14, Testability]shows in the visibility choice: the type isinternalwith anInternalsVisibleTofor the test project, and the project file records exactly why (MMCA.Common/Source/Presentation/MMCA.Common.UI/MMCA.Common.UI.csproj:11-16): the ref-count semantics cannot be reached through the public API, sinceJoinChannelAsyncstarts a realHubConnection, so a join-based test would need a live server and a multi-second backoff.
- Walkthrough: two fields, the
Lock(ChannelReferenceCounter.cs:18) and the outstanding-joinDictionary<string, int>(line 22, whose default string comparer is ordinal, matching the hub's group-name semantics, lines 20-21).AddRef(channelKey)(line 30) reads the current count, writescurrent + 1, and returnscurrent == 0, so only the 0-to-1 transition reports "the server must be told to join" (lines 34-36).Release(channelKey)(line 49) returnsfalsefor a key that was never joined (lines 53-56), removes the entry and returnstruewhen the decrement reaches zero or below (lines 58-63), and otherwise stores the decremented value and returnsfalse(lines 65-66). The count therefore never goes negative and an unpaired leave is a no-op.Snapshot()(line 74) returns[.. _counts.Keys]under the lock: the distinct keys with at least one outstanding join, so a channel held twice is re-joined once.RefCountFor(channelKey)(line 85) returns the outstanding count, or zero when the channel is not held.- Every method takes the lock, because joins and leaves arrive from component lifecycle callbacks on different render batches (lines 11-14).
- Why it's built this way:
ADR-039 decided the shape this
implements: one hub,
JoinChannel/LeaveChannelmapping a connection into a SignalR group, multicast subscriptions so an invisible listener and a page can observe the same channel concurrently, and a re-join onReconnectedbecause group membership does not survive a new connection. The ADR says the hub service tracks membership; this class is how that tracking is done so two concurrent holders cannot evict each other. - Where it's used: three call sites, all inside NotificationHubService:
AddRefinJoinChannelAsync(NotificationHubService.cs:324, deliberately counted before the connection is started so the replay insideStartAsyncsees it, line 196),ReleaseinLeaveChannelAsync(line 229), andSnapshotinRejoinChannelsAsync(line 351). Covered directly byChannelReferenceCounterTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Notifications/NotificationHubServiceTests.cs:569, whose summary names the H13 regression it locks down, lines 314-319). The two concurrent holders it exists for are real: LiveEventListener and the HappeningNow page both join the same event channel key. - Caveats / not-in-source: it counts joins only; it knows nothing about handlers. Subscriptions
live in a separate
_channelSubscriptionsdictionary under a different lock (NotificationHubService.cs:55,63), so disposing a ChannelSubscription does not decrement the count, and leaving a channel does not remove handlers (NotificationHubService.cs:348-349states that pairing requirement).
INotificationScopeProvider
MMCA.Common.UI ·
MMCA.Common.UI.Services.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/INotificationScopeProvider.cs:15· Level 0 · interface
- What it is: the contract that supplies the scope key the notification UI sends and reads under, plus an optional human-readable name for that scope, so an application can narrow its notifications to whatever it considers "current" (a conference event, a tenant, a season) without the framework knowing what that is.
- Depends on: nothing first-party. Both members return
Task<string?>and take aCancellationToken(BCL). Implemented in the framework by NullNotificationScopeProvider and consumed by both notification HTTP services, NotificationInboxService and PushNotificationService. - Concept introduced, the opaque scope key. The framework ships the notification feature but has
no vocabulary for what notifications belong to, so it inverts the question: the app answers with a
string and the framework treats it as opaque (
INotificationScopeProvider.cs:3-8). The value of putting one provider behind both HTTP services is agreement: a send and the reads that follow it resolve through the same instance, so the inbox, the unread badge and a bulk mark-read cannot disagree about which slice the user is looking at.[Rubric §9, API & Contract Design]assesses contract minimality and the meaning of defaults. Both members return a nullable string, and null carries a defined meaning ("unscoped", "no caption"), which is what lets the scoped and unscoped worlds share one code path instead of branching.- A default interface method as a non-breaking extension.
GetCurrentScopeDisplayNameAsyncis declared with a body,=> Task.FromResult<string?>(null)(INotificationScopeProvider.cs:41), so an application with no display name, and every implementation written before the member existed, keeps compiling untouched (the rationale is stated in the doc at lines 31-37).[Rubric §15, Best Practices & Code Quality]assesses whether a contract can grow without a coordinated sweep across every implementor; a default member is the language feature that makes that possible here. [Rubric §11, Security]assesses where authorization decisions live, and this contract is explicit that it is not one. The remarks require implementations never to throw, and they state the direction to fail in: in an application whose notifications are all scoped, fail closed, that is, return the last known scope key or fail the operation, rather than returning null, because degrading to null silently widens the view to every notification (INotificationScopeProvider.cs:9-14). Null is reserved for an application that genuinely runs unscoped. Ownership filtering itself stays on the server: a scope is a view filter, not a permission.- The display-name member fails closed differently, and the source says so: a missing caption hides
information, while a wrong one would state the wrong audience, so returning null is the safe
direction there (
INotificationScopeProvider.cs:34-36).
- Walkthrough: two members.
Task<string?> GetCurrentScopeKeyAsync(CancellationToken ct = default)(INotificationScopeProvider.cs:22) returns the key currently in force (the example in source is"event:2") or null when the application is unscoped (lines 17-21).Task<string?> GetCurrentScopeDisplayNameAsync(CancellationToken ct = default)(line 41) returns a human-readable name for that scope (the conference event's title, the tenant's name). The send page uses it to caption which scope the notification will be tagged with, so an operator can see the auto-applied scope rather than infer it (lines 24-30). The doc is explicit that the scope only decides which inbox view lists the notification and does not narrow delivery, which goes to every recipient the application's recipient provider returns, so the caption must not be read as the audience (lines 27-30). The one call site isMMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Notifications/NotificationSend.razor.cs:78.
- Why it's built this way: an interface rather than a settings value, because the answer is dynamic
(it changes as the app's current context changes) and may need an async lookup. Keeping the key a
plain string keeps the framework free of any domain concept, and the never-throw rule written into
the contract is what makes the fail-closed guarantee real rather than aspirational. See
ADR-024, which records the
optional
ScopeKeytravelling with a send. - Where it's used: injected into NotificationInboxService
(
NotificationInboxService.cs:32) and PushNotificationService (PushNotificationService.cs:19); registered withTryAddScopedagainst NullNotificationScopeProvider byAddNotificationUI()(MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/DependencyInjection.cs:24). In MMCA.ADC the real implementation is CurrentEventNotificationScopeProvider.
NotificationState
MMCA.Common.UI ·
MMCA.Common.UI.Services.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/NotificationState.cs:18· Level 0 · class (sealed)
- What it is: the scoped shared state for the notification unread count. It holds the count, the timestamp of when that count was last established, and the single active-poller slot that keeps duplicate notification bells from each running their own poll loop.
- Depends on: nothing first-party. Externals: BCL
TimeProvider(injected, defaulting toTimeProvider.System,NotificationState.cs:18,21),System.Threading.Lock, and threeEventHandlerevents. Consumed by NotificationBell and the inbox page NotificationInbox. - Concept introduced, a scoped state store that owns both the value and its freshness.
[Rubric §19, State Management & Data Flow]assesses how shared UI state is owned and observed without threading it through the component tree.NotificationStateis registered scoped (MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/DependencyInjection.cs:33), so each Blazor circuit gets its own instance and components subscribe to its events instead of receiving cascading parameters. Two mechanisms are worth studying.- The staleness stamp.
LastFetchedUtc(line 41) records when the count was established, which is the state half of the client's staleness policy: a subscriber that fires on an ambient trigger (a navigation, a re-render) asksIsStale(maxAge)instead of re-reading the API every time the trigger happens to fire (lines 77-85). The subtle rule is stamped inSetUnreadCount: the stamp is written before the unchanged-count early return (line 66), because an API read that came back with the same number is still a read. Without that ordering, a quiet inbox (where the count almost never changes, so almost every read is the no-op read) would look permanently stale and re-fetch forever (lines 63-65).[Rubric §23, Front-End Performance]is the payoff. - The active-poller slot as an owner reference, not a counter.
_pollerOwner(line 29) holds the component instance that currently polls, or null when the slot is free, and the field's own doc explains why a counter was the wrong shape: a counter leaks one increment per teardown that never unregisters, and once it leaks no bell can ever win the slot again for the life of the circuit (lines 23-28). An owner reference makes register and unregister symmetric. [Rubric §14, Testability]shows in the constructor: the clock is aTimeProvider?parameter defaulting toTimeProvider.System(lines 14-18,21), so a test drivesIsStalewith a fake clock while an existing host keeps the previous no-argument constructor shape.
- The staleness stamp.
- Walkthrough: members in teaching order.
- Fields: the
Lock _pollerSync(line 20), the resolved_timeProvider(line 21), and the nullable_pollerOwner(line 29). UnreadCountwith a private setter (line 32) andLastFetchedUtcwith a private setter (line 41).- Three events:
OnChangewhen the count changes (line 44),OnRefreshRequestedwhen a real-time notification arrives and the badge should refetch the authoritative count (lines 46-50), andOnPollerSlotFreedwhen the active-poller slot becomes free so a surviving bell can take polling over (lines 52-57). SetUnreadCount(int)(lines 61-75) stampsLastFetchedUtcfirst (line 66), returns early when the value is unchanged (lines 68-71), and otherwise assigns and raisesOnChange.IsStale(TimeSpan maxAge)(lines 84-85) istruewhen there is no stamp at all or the stamp is older thanmaxAge;MarkStale()(line 92) discards the stamp outright, for a subscriber that learned the data moved (a real-time push) and knows age is no longer evidence of freshness.IncrementUnreadCount()(lines 95-99) bumps by one for an optimistic real-time update and always raisesOnChange.RequestRefresh()(line 102) raisesOnRefreshRequested.TryRegisterPoller(object owner)(lines 111-125) null-guards, takes_pollerSync, and returnsfalseonly when a different owner already holds the slot (lines 117-120); a caller that already holds it getstrueagain, so the call is idempotent.UnregisterPoller(object owner)(lines 133-150) releases the slot only when the caller is the holder (lines 139-142), so a non-owner disposing cannot evict the live poller.OnPollerSlotFreedis raised outside the lock (lines 147-149), because a subscriber claims the slot from its handler and would otherwise re-enter the lock on the disposing component's thread.
- Fields: the
- Why it's built this way: scoped because the count is per-user-session; event-based because subscribers live at arbitrary render-tree depth. The private setters funnel every mutation through the named methods, so no change can bypass the change-notification path or the freshness stamp. The owner-keyed slot plus the freed event is what survives the real lifecycle in a Blazor shell, where the desktop and mobile bell placements are rebuilt independently whenever the authentication state changes (lines 52-56).
- Where it's used: injected into NotificationBell, which claims the slot with
TryRegisterPoller(this), listens onOnPollerSlotFreedto take over, and gates its navigation refresh onIsStale(MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/Notifications/NotificationBell.razor.cs:52,55,118,130,166,184,267), and into NotificationInbox; driven by real-time pushes that arrive over NotificationHubService. Covered byNotificationStateTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Notifications/NotificationStateTests.cs:13).
NullNotificationScopeProvider
MMCA.Common.UI ·
MMCA.Common.UI.Services.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/NullNotificationScopeProvider.cs:8· Level 1 · class (sealed)
- What it is: the framework's default INotificationScopeProvider: a no-op that always reports "unscoped", so an application that never scopes its notifications keeps exactly the behavior it had before the scope key existed.
- Depends on: INotificationScopeProvider (the interface it
implements);
Task.FromResult(BCL). - Concept reinforced, the Null Object pattern as a registration default. Rather than making the
scope provider optional and null-checking it in both HTTP services, the framework registers a
do-nothing implementation and lets the consumers depend on the interface unconditionally.
[Rubric §2, Design Patterns]assesses whether a pattern is used where it removes branching, which is exactly what happens here: NotificationInboxService and PushNotificationService contain no "is a provider registered" test.[Rubric §15, Best Practices & Code Quality]follows: the feature was additive, and an app that ignores it sees a byte-identical request. - Walkthrough: the whole type is one expression-bodied member,
GetCurrentScopeKeyAsync(CancellationToken ct = default) => Task.FromResult<string?>(null)(NullNotificationScopeProvider.cs:10-12). It does not overrideGetCurrentScopeDisplayNameAsync, which is why that member was added to the interface with a default body: the null object inherits the interface's own null answer unchanged. The class doc records the registration contract: it is the default wired byAddNotificationUI, and an app that scopes registers its own implementation, which wins (NullNotificationScopeProvider.cs:3-7). - Why it's built this way: the "wins" part is mechanical, not conventional.
AddNotificationUI()registers this type withTryAddScoped(MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/DependencyInjection.cs:24), and the source comment states the reason:TryAddmeans an app that registers its own provider wins whichever order the two registration calls run in (lines 22-23). A plainAddScopedwould have made host startup ordering load-bearing. - Where it's used: resolved as INotificationScopeProvider by NotificationInboxService and PushNotificationService in every host that has not registered its own; MMCA.ADC replaces it with CurrentEventNotificationScopeProvider.
ChannelSubscription
MMCA.Common.UI ·
MMCA.Common.UI.Services.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/NotificationHubService.cs:771· Level 2 · class (private, sealed, nested)
- What it is: the disposable handle returned when a caller subscribes to a live channel on NotificationHubService; disposing it removes the handler from the channel's subscriber list.
- Depends on: its owning NotificationHubService (a back-reference), a
channel-key string, and a
Func<string, string, Task>handler; implementsIDisposable. - Concept introduced, subscription-as-token. This is the classic "return an
IDisposableto unsubscribe" pattern. Instead of exposing anUnsubscribe(handler)method (which forces callers to hold and match the exact delegate),OnChannelEventreturns aChannelSubscription; when the component disposes it, the subscription calls back into the owner to unregister itself.[Rubric §1, SOLID]shows in the encapsulation: only the hub service can construct one, and only it knows how to remove one, so the bookkeeping has a single owner. - Walkthrough: a primary-constructor nested class capturing
owner,channelKeyandhandler(NotificationHubService.cs:771), exposingChannelKey(line 414) andHandler(line 416) as get-only properties.Dispose()(line 418) simply callsowner.RemoveSubscription(this), which takes the shared_channelSynclock, removes the entry, and prunes the channel's list once it empties (lines 373-386). TheHandlerproperty is whatDispatchChannelEventAsyncinvokes on each delivery (line 339). - Why it's built this way: nesting it privately inside
NotificationHubService keeps subscription bookkeeping fully encapsulated,
and the
IDisposableshape lets Blazor components tie unsubscription to their own lifetime. - Where it's used: constructed and returned by
NotificationHubService
.OnChannelEvent(line 260); disposed by the component that subscribed. - Caveats / not-in-source: disposing a subscription unregisters the handler only. It does not
release a channel join: those are counted separately by
ChannelReferenceCounter, and the source states the pairing requirement
explicitly (
NotificationHubService.cs:348-349).
UnboundedReconnectPolicy
MMCA.Common.UI ·
MMCA.Common.UI.Services.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/UnboundedReconnectPolicy.cs:21· Level 0 · class (internal, sealed)
- What it is: the SignalR automatic-reconnect schedule for the notification hub. It never gives up and never waits longer than 30 seconds between attempts, with one exception: when the server refused authentication, it stops.
- Depends on: nothing first-party. Externals:
IRetryPolicyandRetryContextfromMicrosoft.AspNetCore.SignalR.Client,HttpRequestExceptionandHttpStatusCode(BCL). - Concept introduced, an unbounded retry schedule with a terminal auth exception.
[Rubric §29, Resilience & Business Continuity]assesses recovery from outages of unknown length. The SignalR default schedule gives up after four attempts (0, 2, 10 and 30 seconds), which leaves live notifications dead for the rest of a session after a longer outage; this policy replaces the give-up with a capped delay, so an outage of any length is recovered withinMaxDelayof the network returning (UnboundedReconnectPolicy.cs:24). The one deliberate stop is a refused authentication, since an expired session cannot recover by retrying the same credentials (lines 39-42). - Walkthrough:
MaxDelay(line 24) isinternal staticat 30 seconds so the hub's restart loop reuses the same cap (NotificationHubServiceMaxRetryDelay,NotificationHubService.cs:124).WarmUp(lines 26-31) is the first four delays: zero, 2, 5 and 10 seconds.NextRetryDelay(line 35) null-checks the context, returnsnull(stop) whenIsAuthenticationRefused(retryContext.RetryReason)(lines 39-42), otherwise indexesWarmUpbyPreviousRetryCountand falls back toMaxDelayonce the warm-up is spent (lines 44-46).IsAuthenticationRefused(line 55) walks the exception and itsInnerExceptionchain looking for anHttpRequestExceptionwhoseStatusCodeisUnauthorizedorForbidden(lines 57-62), and for anAggregateExceptionit answers from any member (lines 64-67). The hub's socket factory rethrows a refused WebSocket handshake as exactly that exception so this check can see the status (NotificationHubService.cs:549-608).
- Why it's built this way: a policy class instead of the lambda overload keeps the auth-refusal rule
testable on its own and shared with the start loop in
NotificationHubService(line 280), so the automatic reconnect and the restart loop agree on when to stop. It isinternal sealedbecause it is an implementation detail of the hub service. The hub class doc states the contract (NotificationHubService.cs:32-41). - Where it's used: passed to
WithAutomaticReconnect(new UnboundedReconnectPolicy())by NotificationHubService (NotificationHubService.cs:206), and itsIsAuthenticationRefusedis called again from the start loop (NotificationHubService.cs:280).
NotificationHubService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/NotificationHubService.cs:44· Level 2 · class (sealed, partial)
- What it is: the client-side SignalR connection manager. It opens a connection to
/hubs/notificationsafter login, invokes a callback for received notifications (after an optional per-notification filter), and carries the ephemeral live-channel events that components join and subscribe to. - Depends on: ApiSettings (for the hub URL, via
IOptions<ApiSettings>, readingSameOriginApiEndpointfirst andApiEndpointsecond), ITokenStorageService (for the bearer token on a direct connection); UnboundedReconnectPolicy (the reconnect schedule and the auth-refusal test); ChannelReferenceCounter (membership counting) and ChannelSubscription (its subscription handle). Externals:Microsoft.AspNetCore.SignalR.Client(HubConnection,HubConnectionBuilder),Microsoft.AspNetCore.Http.Connections.Client(HttpConnectionOptions),ClientWebSocket,ILogger<T>with[LoggerMessage]source generation,System.Threading.Lock,SemaphoreSlimandCancellationTokenSource; implementsIAsyncDisposable. - Concept introduced, resilient client-side real-time with re-joinable channels and a lifetime.
[Rubric §6, CQRS & Event-Driven]extends to the browser here: the server pushes notifications and channel events over SignalR instead of the client polling for everything.[Rubric §29, Resilience & Business Continuity]shows in six distinct mechanisms, each with its rationale in source:- the initial connect retries with exponential backoff up to
MaxRetries = 3starting atInitialRetryDelayof 2 seconds and doubling (lines 46, 118, 254-302), so a token-not-yet-ready or API-still-starting race recovers; WithAutomaticReconnect(new UnboundedReconnectPolicy())(line 206) keeps long sessions alive for as long as the service lives, never giving up and waiting at most 30 seconds between attempts, and because SignalR group membership does not survive a new connection,Reconnectedre-joins every held channel (line 226,RejoinChannelsAsynclines 698-721);- if the connection still closes with an error (the server refused the reconnect),
ClosedrunsRestartAfterCloseAsync(line 231, lines 633-667): a loop that repeats the serialized start, with a doubling backoff capped atMaxRetryDelay(line 124, defaulting toUnboundedReconnectPolicy.MaxDelay), until one connects, so live notifications do not stay dead for the rest of the session. AClosedwith a null error is a deliberate stop or dispose and is never restarted (line 231; class doc lines 32-41); - a lifetime token ends every start and restart loop on
StopAsyncorDisposeAsync._lifetime(line 71) is canceled and replaced byStopCoreAsync(lines 436-480); each loop runs under the token it began in and re-checksIsLifetimeOver(line 669) under_startSyncbefore building or starting a connection, so nothing begun before a stop connects after it, and the cancellation also cuts short the backoff delays and an in-flight connect (DelayUnlessStoppedAsync, lines 498-508); - a refused authentication (401 or 403) ends both the automatic reconnect and the restart loop: the
start loop logs
LogAuthenticationRefused, discards the connection and returnsfalse(lines 280-287), whichRestartAfterCloseAsyncreads as "stop" (lines 651-656). The nextStartAsynctries again; - a terminal start failure discards the connection object (
DiscardUnstartedConnectionAsync, lines 622-631) instead of leaving it in the field, because a connection that never started must not satisfy the guard inStartCoreAsync, or every laterStartAsync(including one reached throughJoinChannelAsync) would no-op forever (comment at lines 293-295). The guard itself only leaves a connected, connecting or reconnecting connection alone (line 241); aDisconnectedone is discarded and rebuilt (lines 246-249); StartAsyncis serialized by aSemaphoreSlim(line 60), since two components callingJoinChannelAsyncat once on Blazor Server (which has no single synchronization context) could both see a null connection, both build one, and leak the loser socket with duplicate server registrations (doc lines 136-142).[Rubric §13, Observability & Operability]applies too: every outcome is a source-generated structured log (lines 738-769, includingLogReconnectExhaustedat line 748 andLogRestartRetryingat line 751), and failures on the channel paths are logged, never thrown.
- the initial connect retries with exponential backoff up to
- Walkthrough:
- Constants and fields: the four hub method names (lines 47-50),
_sameOriginProxyEndpoint(line 53),_channelSyncguarding the subscription dictionary (line 55),_lifetimeSyncguarding the lifetime swap (line 56),_startSyncguarding start (line 60, aSemaphoreSlimrather than aLockbecause the guarded body awaits, lines 58-59), the ChannelReferenceCounter (line 62), and_channelSubscriptionsmapping a channel key to its handler list (line 63). NotificationCallback(line 77) is a settableFunc<string, string, Task>?the host assigns to surface a snackbar.NotificationFilter(line 85) is an optionalFunc<IReadOnlyDictionary<string, string>?, Task<bool>>consulted with the notification's live metadata (null for an unscoped send) before the callback runs;falsedrops the notification for this client, which is how the listener leaves another scope's notifications out of the toast and the badge.IsConnected(line 112) reports the connection state;InitialRetryDelay(line 118) andMaxRetryDelay(line 124) areinternaland settable so a test can exercise the terminal-failure path and the unbounded restart loop without waiting out the real backoff;ConnectionFactory(line 130) is aninternaltest-only hook that replaces theHubConnectionBuildercall so a test can supply a connection over an in-memory transport, and is null in production.- The constructor (lines 87-103) null-checks the options (line 94), records
ApiSettings.SameOriginApiEndpoint(line 100), and builds_hubUrlfrom that endpoint, falling back toApiSettings.ApiEndpointand throwing when neither is set (line 101), trimmed of its trailing slash plus/hubs/notifications(line 102).UsesSameOriginProxy(line 109) reports which path was taken. The same-origin path serves a WebAssembly client of an opted-in host: the hub is reached through the UI host's/apiproxy, which attaches the bearer from the HttpOnly session cookie on the WebSocket upgrade (the only request, since negotiation is skipped), so the connection carries no client-held token (comment at lines 96-99). StartAsync(lines 144-152) bails when disposed and otherwise callsStartForLifetimeAsyncwith the current lifetime token (CurrentLifetime, lines 482-496).StartForLifetimeAsync(lines 162-200) returnstruewhen the lifetime is over, waits on_startSyncwith the lifetime token (toleratingObjectDisposedExceptionandOperationCanceledException, both treated as "nothing left to start"), re-checks the lifetime under the gate, runsStartCoreAsync, and releases in afinallythat tolerates the disposal race. Itsboolresult isfalseonly when the server refused authentication.BuildConnection(lifetime)(lines 202-234) builds theHubConnectionfromConnectionFactorywhen set or from aHubConnectionBuilderconfigured byConfigureConnectionwith the unbounded reconnect policy (lines 203-207). It registersReceiveNotification, which appliesNotificationFilterand thenNotificationCallback(lines 209-220), andReceiveChannelEventtoDispatchChannelEventAsync(line 222), wires the reconnect re-join (line 226) and the close-with-error restart bound to this connection's lifetime (line 231).StartCoreAsync(lifetime)(lines 236-308) returnstrueimmediately unless the existing connection is null orDisconnected(line 241), discards a dead one (lines 246-249), builds a connection (line 251), then runs the retry loop: each attempt first checks the lifetime (lines 256-260), starts the connection under the lifetime token (line 264), and on success replays any channel joins requested before the connection came up (line 268). A failure while the lifetime is over is not a failure (lines 273-278); a refused authentication returnsfalse(lines 280-287); the last attempt logs and discards (lines 289-298).ConfigureConnection(options)(lines 533-551) applies the transport options. Both modes skip negotiation and use WebSockets only (lines 537-538): negotiate and connect are separate requests, and behind a non-sticky ingress with several hub replicas the connect can land on a replica that never issued the connection id, so WebSockets are required and there is no Server-Sent Events or long-polling fallback (doc lines 511-516). Through the same-origin proxy it returns early, sending no token and no extra header (lines 540-543). On a direct gateway connection it bindsAccessTokenProvidertoITokenStorageService.GetAccessTokenAsync(line 545) and, outside the browser, installsConnectWebSocketAsyncas the socket factory (lines 547-550).ConnectWebSocketAsync(lines 577-615) opens aClientWebSocket, copies the configured headers and the bearer, and connects. When the server answered the upgrade with a non-101 status it rethrows anHttpRequestExceptioncarrying that status (lines 599-609), which is what letsUnboundedReconnectPolicy.IsAuthenticationRefusedrecognise a 401 or 403; a bareWebSocketExceptionwould not carry it.JoinChannelAsync(channelKey)(lines 319-341) counts the join before starting the connection so the replay insideStartAsyncsees it (lines 323-326), then invokes the serverJoinChannelonly on the first join and only when connected (lines 328-340).LeaveChannelAsync(channelKey)(lines 352-371) is the mirror: it invokesLeaveChannelonly whenReleasereports the last outstanding leave (lines 356-370).OnChannelEvent(channelKey, handler)(lines 382-400) creates a ChannelSubscription and appends it to the channel's handler list under the lock, returning the subscription as the unsubscribe token. Subscribing deliberately does not join the channel; the doc says to callJoinChannelAsyncas well (lines 376-377).DispatchChannelEventAsync(lines 671-696) snapshots the subscriber list under the lock (line 681), then invokes each handler in isolation, logging (never rethrowing) a failure so one bad subscriber cannot starve the rest (lines 684-695).StopAsync(line 410) is a no-op once disposed and otherwise callsStopCoreAsync(lines 436-480), which swaps in a freshCancellationTokenSourceunder_lifetimeSync, cancels and disposes the ended one, then takes_startSyncso no start can build anything after the stop returns, and disposes and clears the connection.DisposeAsync(lines 413-434) sets_disposed, stops, disposes the semaphore, and cancels and disposes the final lifetime. The comment at lines 423-424 records why that is safe: the stop already canceled any start in flight and waited for it to release the gate.RestartAfterCloseAsyncalso checks the lifetime first (lines 635-638), so a close during teardown starts nothing.
- Constants and fields: the four hub method names (lines 47-50),
- Why it's built this way: sealed and scoped per circuit, because a connection and its channel
membership are per-user-session. Best-effort semantics (join, leave and handler failures are logged,
not thrown) match the reality that live updates are a convenience layered over the authoritative API,
not a correctness guarantee, and isolating handler invocations protects the fan-out. Binding every
start and restart to a lifetime token is what makes an unbounded retry safe: without it a loop that
never gives up could outlive a logout or a disposed circuit. The overall shape is the client half of
ADR-039, with push notifications
themselves covered by
ADR-024. Choosing the transport
once in the constructor, and keeping the token-or-no-token decision in
ConfigureConnection, is what lets the same service run either directly against the gateway or behind the same-origin API proxy of ADR-131 without a client-held token. - Where it's used: registered as a scoped service by
AddNotificationUI()(MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/DependencyInjection.cs:36); started after login and stopped on logout; its notification callback drives NotificationState and MudBlazor snackbars, and its channel API is what LiveEventListener and the HappeningNow page use. The server side is NotificationHub.
INotificationInboxUIService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/INotificationInboxUIService.cs:11· Level 3 · interface
- What it is: the UI-side contract for the per-user notification inbox: paged retrieval, unread count, mark-one-read, and mark-all-read. Every member returns a Result carrying the API's own errors.
- Depends on: Result and its generic form
Result<T>, PagedCollectionResult<T>, and UserNotificationDTO (its return shapes), plus theUserNotificationIdentifierTypealias (INotificationInboxUIService.cs:26). - Concept introduced, the Result pattern carried all the way to the component.
[Rubric §18, UI Architecture & Component Design]assesses whether components talk to typed services rather than rawHttpClient; components depend on this interface, not on the HTTP implementation, so a bell or an inbox page can be tested against a stub. The sharper point is the return shape: every member isTask<Result...>, so a caller can tell a real answer apart from a failure without catching anything (INotificationInboxUIService.cs:6-10). See the primer's Result section for the pattern itself; this is where it crosses the presentation boundary.[Rubric §24, Forms, Validation & UX Safety]shows in the unread-count doc (lines 16-21), which defines what a failure means to a caller: the count could not be established (expired session, transient failure) and must be treated as "unknown". Callers leave the displayed count untouched, because reporting zero would erase a badge that a real-time push had just incremented. That is a contract-level statement about UI behavior, not just about data.[Rubric §9, API & Contract Design]shows in the paged signature: the inbox is fetched a page at a time with sane defaults, never as one unbounded dump.
- Walkthrough: four members (
INotificationInboxUIService.cs:13-29).GetInboxAsync(pageNumber = 1, pageSize = 20, cancellationToken)returns aResult<PagedCollectionResult<UserNotificationDTO>>(line 14);GetUnreadCountAsyncreturnsResult<int>(line 23);MarkReadAsync(id, ct)(line 26) andMarkAllReadAsync(ct)(line 29) are the two mutations, both returning a bare Result. - Why it's built this way: a thin interface at the presentation edge keeps components decoupled from transport and makes the inbox mockable in bUnit tests. Note the contract deliberately says nothing about scoping: the scope key is resolved inside the implementation through INotificationScopeProvider, so adding scoping did not change this interface or any caller.
- Where it's used: implemented by NotificationInboxService; consumed by NotificationBell (for the unread count) and the NotificationInbox page.
IPushNotificationUIService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/IPushNotificationUIService.cs:10· Level 3 · interface
- What it is: the UI-side contract for admin push operations: broadcast a notification and read paginated send history, both returning a Result.
- Depends on: Result, PagedCollectionResult<T>, PushNotificationDTO, and SendPushNotificationRequest.
- Concept reinforced: the same Result-returning UI-service abstraction as
INotificationInboxUIService (
[Rubric §18, UI Architecture & Component Design]), with the same "errors are values the API described, not exceptions the caller catches" rule stated in the doc (IPushNotificationUIService.cs:6-9). The difference is audience: this is the organizer/admin surface (send plus history), not the per-user inbox, and splitting the two keeps each page's dependency surface minimal. - Walkthrough: two members (
IPushNotificationUIService.cs:12-16).SendAsync(SendPushNotificationRequest, ct)returnsResult<PushNotificationDTO>for the created notification (line 13);GetHistoryAsync(pageNumber = 1, pageSize = 10, ct)returnsResult<PagedCollectionResult<PushNotificationDTO>>(line 16). - Why it's built this way: separating the admin contract from the inbox contract lets an app that never sends notifications avoid taking a dependency on the send path at all, and keeps the two registrations independent.
- Where it's used: implemented by PushNotificationService; consumed by the admin pages NotificationList and NotificationSend.
NotificationInboxService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/NotificationInboxService.cs:29· Level 4 · class (sealed)
- What it is: the HTTP implementation of the inbox contract. It calls the
notifications/inboxWebAPI resource for paged retrieval, unread count, and the two mark-read operations, stamping every scopeable request with the application's current scope key and giving the two reads one forced token refresh and replay when the API answers401. - Depends on: AuthenticatedServiceBase (its base, supplying
CreateAuthenticatedClientAsync,CreateClientWithTokenand the shared staticRetryPolicy), INotificationInboxUIService (the contract it implements), ITokenStorageService, INotificationScopeProvider, ITokenRefresher (optional, defaulted to null), HttpResultExecutor (turns a thrown transport failure into a failed Result), ProblemDetailsResultReader (turns the response into a Result carrying the API's ownProblemDetailserrors), PagedCollectionResult<T>, and UserNotificationDTO. Externals:IHttpClientFactory,System.Net.HttpStatusCode,CultureInfo.InvariantCulture. - Concept introduced, a typed HTTP UI service over a non-CRUD resource.
[Rubric §18, UI Architecture & Component Design]assesses UI-to-API access through typed services. This is a sibling of EntityServiceBase<TEntityDTO, TIdentifierType> for a resource whose verbs are read and mark rather than create/update/delete, so it inherits only the authenticated-client half. Every method has the same three-layer shape:HttpResultExecutor.ExecuteAsyncon the outside (exceptions become failed Results), the sharedRetryPolicyin the middle, andProblemDetailsResultReader.ReadAsyncat the end (a non-success response becomes the failure the API described).- The 401 refresh-and-replay, and why only reads get it.
SendReadWithAuthRefreshAsync(lines 128-153) is the interesting mechanism: a badge poll or an inbox load that lands on an access token the server has already rejected gets one forced token refresh and one replay, instead of surfacing as an empty inbox or a blanked badge (lines 16-19). The doc states the constraint that makes it safe (lines 121-125): only the reads use it, because they are safe to replay, whereas a mark-read PUT is left with its existing single-shot behavior.[Rubric §29, Resilience & Business Continuity]is the category;[Rubric §11, Security]is the reason it is bounded, one attempt, no loop. - A failure is "unknown", deliberately not zero. The comment on the unread count (lines 73-75)
records the defect this shape fixed: reporting zero let a rejected token or a transient failure
erase a badge that a real-time push had just incremented. Now the failure travels as a failed
Result and the caller keeps the displayed count.
[Rubric §24, Forms, Validation & UX Safety]covers this class of "what does the UI show when the read failed" decision. [Rubric §30, Compliance, Privacy & Data Governance]shows in the scope query: the scope is what keeps a bulk mark-read from silently clearing notifications the user is not currently looking at.
- The 401 refresh-and-replay, and why only reads get it.
- Walkthrough:
- A primary constructor forwards
IHttpClientFactoryand ITokenStorageService to AuthenticatedServiceBase and keepsscopeProviderand the optionaltokenRefresher(NotificationInboxService.cs:29-34);Endpointis the constant"notifications/inbox"(line 36);_refreshSyncand_refreshInFlight(lines 38-39) hold the single-flight token refresh. The refresher's default ofnullis documented as the graceful-degradation path: a host that registers none simply skips the retry, and the read reports failure rather than a fabricated empty result (lines 24-27). ScopeQueryAsync(separator, ct)(lines 225-232) is the shared helper: it asks INotificationScopeProvider for the current key and returns either an empty string (leaving the request byte-identical to the pre-scope one, lines 217-220) or{separator}scope={Uri.EscapeDataString(scopeKey)}(line 231). The separator parameter is"&"for a URL that already carries query parameters and"?"for one that does not (lines 221-223).GetInboxAsync(lines 42-60) resolves the scope with"&"(line 49), builds an invariant-culture relative URL (lines 50-52), sends throughSendReadWithAuthRefreshAsync(lines 54-55), and reads aResult<PagedCollectionResult<UserNotificationDTO>>(lines 57-58).GetUnreadCountAsync(lines 63-78) resolves the scope with"?"(line 67), goes through the same read path, and reads aResult<int>(line 76).MarkReadAsync(lines 81-97) PUTs to{Endpoint}/{id}/read(line 88) on a plain authenticated client. It is the one method that sends no scope: the id already identifies a single notification.MarkAllReadAsync(lines 100-115) PUTs to{Endpoint}/read-allwith the scope query (lines 104-106), so a bulk operation is bounded by the same filter the list was read under.- Both mutations pass the
cancellationTokeninto the retry policy as well as the request (lines 90-93, 108-111), and the comment says why: without it an abandoned mark-read sleeps out its full backoff budget instead of aborting. SendReadWithAuthRefreshAsync(lines 128-153) runs the send under the retry policy inside ausingfor the first client (lines 133-136), returns the response untouched when it is not a401or no refresher was registered (lines 138-141), and otherwise acquires a token, disposes the first response, and replays on a client built with the new token (lines 143-152). The doc notes the response content is fully buffered before the send task completes, which is what makes it legal to read the body after the client that produced it is disposed (lines 124-125).TryAcquireRefreshedTokenAsync(lines 168-201) is single-flight, with the same shape as the token storage's hydrate: two reads that both land on a rejected token (the bell poll and the inbox page share this scoped instance) share one rotation, because on MAUI a second rotation presenting the same refresh token is read as reuse and revokes the whole session (BR-206; remarks at lines 159-166). Under_refreshSyncit startsAcquireRefreshedTokenAsynconly when no refresh is in flight or the stored one has completed (lines 175-183), underCancellationToken.Noneso one caller giving up cannot cancel the refresh another is awaiting (comment at lines 170-173). Each caller stops waiting on its own token viaWaitAsync(line 187), and thefinallyclears only its own finished task, so a still-running or newer refresh is not dropped (lines 189-200). The residual is stated in the remarks: it does not coordinate with the storage's own expiry-driven hydrate (lines 163-166).AcquireRefreshedTokenAsync(lines 203-215) forces one re-acquisition and returnsnullfor a blank token or when JS interop is unavailable during SSR prerender (lines 210-213), which is a "no refresh is possible here", not an error.
- A primary constructor forwards
- Why it's built this way: inheriting from AuthenticatedServiceBase
removes per-method boilerplate for auth and retry, and wrapping every body in
HttpResultExecutor means no method has to hand-write a try/catch to honor the
Result-returning contract. Routing the scope through a provider (rather than a parameter on every
call) is what keeps the UI contract unchanged while the inbox, badge and mark-all agree on one slice
(
NotificationInboxService.cs:11-16). - Where it's used: registered against
INotificationInboxUIService as scoped by
AddNotificationUI()(MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/DependencyInjection.cs:30); consumed by NotificationBell and the NotificationInbox page.
PushNotificationService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/PushNotificationService.cs:16· Level 5 · class (sealed)
- What it is: the HTTP implementation of the admin push contract: send a notification and read
paginated send history against the
notificationsWebAPI resource, stamping a send with the application's current scope key when the caller did not name one. - Depends on:
EntityServiceBase<TEntityDTO, TIdentifierType>
(its base, typed on
PushNotificationDTO/PushNotificationIdentifierType, which suppliesEndpointand the Result-returningSendRequestAsync), IPushNotificationUIService (the contract), ITokenStorageService, INotificationScopeProvider, Result, PagedCollectionResult<T>, PushNotificationDTO, and SendPushNotificationRequest. - Concept reinforced, the base-class HTTP service pattern at its cleanest
(
[Rubric §18, UI Architecture & Component Design]). Where NotificationInboxService hand-builds each request because its resource is not CRUD-shaped, this one leans on EntityServiceBase'sSendRequestAsync, so each method reduces to one send that already returns a Result.[Rubric §9, API & Contract Design]appears in the scope precedence rule: an explicit caller choice outranks the ambient one, which is the difference between a default and an override. - Walkthrough:
- The primary constructor passes the resource name
"notifications"plus the factory and token service to EntityServiceBase and keepsscopeProvider(PushNotificationService.cs:16-22). SendAsync(request, ct)(lines 23-52) null-guards the request (line 28), then applies scoping conditionally: a request that already carries aScopeKeyis sent unchanged, and only an unscoped one picks up the ambient key via arecord withexpression (lines 32-40, rationale at lines 30-31). It then POSTs throughSendRequestAsync<PushNotificationDTO>with a freshidempotencyKey: NewIdempotencyKey()(lines 45-51). The send is retried on a transient failure, so without a key an attempt that reached the server before failing would broadcast twice; the key rides on every attempt so the[Idempotent]endpoint collapses the duplicate (comment at lines 42-44).NewIdempotencyKey()is a GUID inNformat (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/AuthenticatedServiceBase.cs:51), and the base sets it as a default header on the one client that serves every retry attempt (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/EntityServiceBase.cs:407-414).GetHistoryAsync(pageNumber = 1, pageSize = 10, ct)(lines 54-64) builds an invariant-culturepageNumber/pageSizequery (line 60) and sends a GET through the same helper (lines 61-63), with no idempotency key since a read is safe to repeat. Note it does not send a scope: history is the admin's full send log.
- The primary constructor passes the resource name
- Why it's built this way: delegating transport, auth, retry and error translation to
EntityServiceBase keeps this class down to two short
methods, matching the framework's "UI services are typed HTTP clients, never raw
HttpClient" convention. Reading the scope through the same INotificationScopeProvider the inbox service uses is what makes a send and the reads that follow it resolve to one scope (PushNotificationService.cs:10-15); the wire-levelScopeKeyon the request is recorded in ADR-024. - Where it's used: registered against
IPushNotificationUIService as scoped by
AddNotificationUI()(MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/DependencyInjection.cs:27); injected into the admin pages NotificationList and NotificationSend. - Caveats / not-in-source:
SendAsyncdoes not readGetCurrentScopeDisplayNameAsync; that member exists for the send page's caption and is consumed by NotificationSend directly (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Notifications/NotificationSend.razor.cs:78), not by this service.
BrandColors
MMCA.Common.UI ·
MMCA.Common.UI.Theme·MMCA.Common/Source/Presentation/MMCA.Common.UI/Theme/BrandColors.cs:10· Level 0 · class (static)
- What it is: the single C# source of truth for every hex value in the theme. It holds the two brand triads (primary and secondary), the app-chrome colors shared by both modes, and the full light and dark palettes, so every hex value MMCATheme assigns in either MudBlazor variant is one of these constants (doc comment,
MMCA.Common/Source/Presentation/MMCA.Common.UI/Theme/BrandColors.cs:4-5). - Depends on: nothing first-party. Part of it is mirrored by CSS custom properties in
wwwroot/app.css; the doc comments name--mmca-primary,--mmca-primary-dark,--mmca-primary-light,--mmca-secondary,--mmca-secondary-dark(BrandColors.cs:12,15,18,22,28), plus--mmca-sidebar-bgonChromeBackground(line 36) and--mmca-divideronLightDivider(line 80). - Concept introduced, a fitness-tested duplication.
[Rubric §20, Design System & Theming]assesses whether visual tokens are centralized rather than scattered as literals; here the whole palette lives in exactly one C# class, and the theme carries no hex literal of its own.[Rubric §34, Architecture Governance & Documentation]assesses whether necessary duplication is monitored: C# cannot read CSS at build time, so the brand colors must exist in bothBrandColorsandapp.css, andBrandColorTokenTestsin MMCA.Common.UI.Tests asserts the two stay in sync so the copy cannot silently drift (BrandColors.cs:6-8). The test pins the five brand tokens, oneInlineDatarow each (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Theme/BrandColorTokenTests.cs:35-39); the--mmca-sidebar-bgand--mmca-dividermirrors are named in the doc comments but have no row there.[Rubric §21, Accessibility]lands here rather than only in the theme: theSecondaryconstant carries its own contrast math in source, Teal 700#00796Bholding about 5.3:1 on light surfaces, replacing the Teal 600#00897Bthat measured about 4.0:1 and sat under the WCAG 2.1 AA 4.5:1 floor for normal text (BrandColors.cs:21-26). The contrast reasoning forLightWarningandDarkErroris deliberately not here: their summaries point at MMCATheme, where the math sits beside the assignment (lines 56, 109).
- Walkthrough:
public const stringfields in four commented groups.- The primary triad:
Primary = "#1565C0"(line 13),PrimaryDark = "#0D47A1"(line 16),PrimaryLight = "#42A5F5"used for accents and dark-mode contrast (line 19). The secondary triad:Secondary = "#00796B"(line 26, with the contrast rationale immediately above it at lines 21-25),SecondaryDark = "#00695C"(line 29), andSecondaryLight = "#4DB6AC"(line 32). - Shared chrome, identical in both palettes (line 34):
ChromeBackground = "#1A2035"for the app bar and sidebar (line 37),ChromeText = "#FFFFFF"(line 40), andChromeTextMuted = "#FFFFFFB3", white at 70% alpha for sidebar text and icons (line 43). - The light palette (lines 45-84):
LightTertiaryPurple 700,LightInfoBlue 700,LightSuccessGreen 800,LightWarning = "#A85D00"withLightWarningContrastText = "#FFFFFF",LightErrorRed 800, thenLightBackground = "#FAFBFC",LightSurface, the two text tones,LightActionDefault, and the two dividers. - The dark palette (lines 86-131):
DarkPrimaryLightenBlue 200,DarkSecondaryDarken = "#00897B"(Teal 600, the same value the lightSecondarywas moved off for contrast, line 92),DarkSecondaryLighten,DarkTertiary,DarkInfo,DarkSuccess,DarkWarningOrange 400,DarkError = "#FF8A80"(line 110), thenDarkBackground = "#1A2027",DarkSurface = "#27303A", text, action and divider tones. NoteDarkInfoandPrimaryLightshare#42A5F5, andDarkTextSecondaryandDarkActionDefaultshare#B0BEC5: each keeps its own name so a role can change without dragging the other.
- The primary triad:
- Why it's built this way:
constrather thanstatic readonlymeans the values can appear in contexts that require compile-time constants; the governance is the fitness test, not the language keyword. Keeping every palette value in one class means a rebrand or a contrast fix touches one file plus the mirrored CSS, and the theme reads as a mapping of roles to names rather than a wall of hex. - Where it's used: every palette entry of MMCATheme that is not an
rgba(...)literal (MMCA.Common/Source/Presentation/MMCA.Common.UI/Theme/MMCATheme.cs:18-54,60-120);BrandColorTokenTests; the doc comment ofBrandColorTokenTestsBase, the per-host stylesheet check that consumer repos subclass (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/Ui/BrandColorTokenTestsBase.cs:6-13); any component that references a brand color programmatically.
IUserPreferenceWriter
MMCA.Common.UI ·
MMCA.Common.UI.Services.Preferences·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Preferences/IUserPreferenceWriter.cs:9· Level 0 · interface
- What it is: the write half of cross-device UI preferences. It persists the signed-in user's culture and theme choice to the backend so the choice follows them to their next browser or device.
- Depends on: nothing. Implemented by ApiUserPreferenceWriter; the read half is IUserPreferenceReader.
- Concept introduced, best-effort persistence over a local source of truth.
[Rubric §19, State Management & Data Flow]assesses where state lives and which copy wins; the doc comment (IUserPreferenceWriter.cs:3-8) answers both. The cookie and localStorage remain the runtime channel, this interface is a roaming convenience, and a failed or skipped persist must never break the in-page switch. Implementations must no-op for anonymous users. Anullfield means "leave unchanged" (IUserPreferenceWriter.cs:11-13), which is what lets the theme toggle and the culture switcher share one method without either clobbering the other's value. - Walkthrough
SaveAsync(string? culture, string? theme, CancellationToken cancellationToken = default)(IUserPreferenceWriter.cs:18). Both value arguments are nullable by design, per the null-means-unchanged rule stated atIUserPreferenceWriter.cs:15-16.
- Why it's built this way:
ADR-027 and
ADR-028. Keeping it an interface is
what lets a host with no
auth/preferencesendpoint (the Helpdesk seed is the named example atApiUserPreferenceWriter.cs:11-12) simply not register it: the callers resolve it withGetService<T>and skip the persist when it is absent. - Where it's used: the theme toggle resolves it optionally and saves only the theme
(
MMCA.Common/Source/Presentation/MMCA.Common.UI/Theme/ThemeToggle.razor:23-27); the culture switcher does the same for culture before handing off to the applier (CultureSwitcher.razor:38-42).
UserPreferences
MMCA.Common.UI ·
MMCA.Common.UI.Services.Preferences·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Preferences/UserPreferences.cs:9· Level 0 · record (sealed)
- What it is: the two-field result of reading a user's stored UI preferences, their culture and their theme.
- Depends on: nothing. Returned by IUserPreferenceReader and its implementation ApiUserPreferenceReader.
- Concept introduced: nothing new. It reuses the null-means-unset convention introduced by
IUserPreferenceWriter: the doc comment (
UserPreferences.cs:3-6) states that anullfield means the user never chose that preference, so the request default or the OS preference applies.[Rubric §19, State Management & Data Flow]applies in the small, because "no stored value" and "stored value that happens to be the default" stay distinguishable, which is what lets the login reconciliation skip a redundant culture round-trip. - Walkthrough
- A positional record with two members,
CultureandTheme, bothstring?(UserPreferences.cs:9). There is no factory and no validation: the values are whatever the backend returned.
- A positional record with two members,
- Why it's built this way: a positional record is the smallest thing that deserializes cleanly from
the
auth/preferencespayload and compares by value. - Where it's used: returned by ApiUserPreferenceReader, including its
static
Emptyinstance (ApiUserPreferenceReader.cs:18); consumed by the login page's preference reconciliation (Login.razor:228-247).
UserPreferencesRequest
MMCA.Common.UI ·
MMCA.Common.UI.Services.Preferences·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Preferences/ApiUserPreferenceWriter.cs:29· Level 0 · record (private sealed, nested)
- What it is: the request body the writer PUTs to
auth/preferences, the same culture and theme pair in the write direction. - Depends on: nothing. It is declared inside ApiUserPreferenceWriter and used only there.
- Concept introduced: nothing new; it is the wire-facing twin of
UserPreferences.
[Rubric §9, API & Contract Design]applies in the small: the request type is kept separate from the response type even though the two currently have identical members, so the directions can diverge without a breaking change, and it is declaredprivateso it never becomes part of the package's public surface. - Walkthrough
private sealed record UserPreferencesRequest(string? Culture, string? Theme)(ApiUserPreferenceWriter.cs:29). It is instantiated once, inline in thePutAsJsonAsynccall (ApiUserPreferenceWriter.cs:65).
- Why it's built this way: nesting it privately keeps a serialization detail from leaking into the package API, and a positional record needs no mapper.
- Where it's used: only in
ApiUserPreferenceWriter.SaveAsync(ApiUserPreferenceWriter.cs:63-66).
IInitialThemeModeSource
MMCA.Common.UI ·
MMCA.Common.UI.Theme·MMCA.Common/Source/Presentation/MMCA.Common.UI/Theme/IInitialThemeModeSource.cs:14· Level 0 · interface
- What it is: an optional hook that lets a head tell the theme providers the stored Day/Dark mode without JavaScript, so the first render already has the right palette instead of light-then-dark (ADR-028). Its one member,
bool? IsDarkMode(line 21), istruefor dark,falsefor light, andnullwhen nothing is stored yet, which leaves the first render unchanged. - Depends on: nothing.
- Concept introduced, an optional capability probed with
GetService.[Rubric §20, Design System, Theming & UI Consistency]covers theming that works from the first paint. The provider asks the container for the interface and treats absence andnullidentically (MMCA.Common/Source/Presentation/MMCA.Common.UI/Theme/MmcaThemeProviders.razor:43-50), so web heads register nothing and still behave as before, while the JS path inOnAfterRenderAsyncstays the source of truth for them. - Why it's built this way: the MAUI head can read its stored mode synchronously from device preferences, which a Blazor circuit cannot do with a cookie before the first render. A nullable bool keeps "unknown" distinct from "light".
- Where it's used: implemented by
MauiInitialThemeModeSource(MMCA.Common/Source/Presentation/MMCA.Common.UI.Maui/Theme/MauiInitialThemeModeSource.cs:12) and registered as a singleton (MMCA.Common/Source/Presentation/MMCA.Common.UI.Maui/DependencyInjection.cs:68); consumed byMmcaThemeProviders.razor:47; pinned byMmcaThemeProvidersInitialModeTests.
ThemeInterop
MMCA.Common.UI ·
MMCA.Common.UI.Theme·MMCA.Common/Source/Presentation/MMCA.Common.UI/Theme/ThemeInterop.cs:9· Level 0 · class (internal static)
- What it is: a one-method guard,
TryAsync(Func<Task>)(ThemeInterop.cs:18), that runs a best-effort JS interop call and returnsfalseinstead of throwing when it fails. - Depends on:
Microsoft.JSInteropexception types (JSException,JSDisconnectedException) andInvalidOperationException(BCL). - Concept introduced, theme interop failure must not escape the component lifecycle.
[Rubric §18, UI Architecture & Component Design]covers resilient component lifecycles. The filter catches exactly three cases: the asset is missing or the browser call failed (JSException), the circuit was torn down mid-call (JSDisconnectedException), and interop is unavailable on this renderer (InvalidOperationException, a prerender race or disposed dispatcher). Any other exception still propagates. - Why it's built this way: theming is cosmetic, so a failed cookie read or write degrades to "the page keeps its current mode" rather than killing the circuit from the root layout.
- Where it's used:
MmcaThemeProviders.razor:62,90andThemeToggle.razor:22(all underMMCA.Common/Source/Presentation/MMCA.Common.UI/Theme/). No dedicated test file; it is exercised through the provider and toggle component tests.
ThemeService
MMCA.Common.UI ·
MMCA.Common.UI.Theme·MMCA.Common/Source/Presentation/MMCA.Common.UI/Theme/ThemeService.cs:17· Level 1 · class (sealed)
- What it is: the single owner of the Day/Dark preference (ADR-028). It holds the current mode for
the circuit, persists a change through a small JS module to a cookie plus
localStorage, and raises an event so every subscriber re-renders together. - Depends on: LazyJsModule (single-flight importer for its JS module,
ThemeService.cs:20);Microsoft.JSInterop.IJSRuntime(ASP.NET Core, primary-constructor parameter at line 16); the asset it imports,MMCA.Common/Source/Presentation/MMCA.Common.UI/wwwroot/theme.js, which owns the cookie andlocalStorageaccess (theme.js:5,23,33).IAsyncDisposable(BCL) is implemented so the module reference is released with the circuit. - Concept introduced, one scoped service as the theme's single source of truth.
[Rubric §20, Design System, Theming & UI Consistency]assesses whether theming is a first-class, centrally owned concern rather than per-page CSS toggling. Here exactly one scoped service holdsIsDarkMode(line 22); the toggle button, theMudThemeProviderwrapper and, on MAUI, the native chrome all read from it and all subscribe toOnChange(line 28). Nothing else stores a copy.[Rubric §19, State Management & Data Flow]is the same fact viewed as state: an event-plus-property service is the framework's pattern for cross-component UI state that is not routed, and the cost of that pattern is unsubscription discipline in every consumer. - Concept introduced, JS interop is not available during prerender.
[Rubric §18, UI Architecture & Component Design]covers the render-mode contract a Blazor component must respect (ADR-056,Website/docs-src/adr/056-blazor-render-mode-strategy.md). Reading a cookie orlocalStoragerequires a live browser, soInitializeAsynccan only run after the first interactive render; the class documents that requirement on itself (ThemeService.cs:11-14) rather than guarding it internally, and the component that owns the lifecycle calls it fromOnAfterRenderAsync(firstRender)(MMCA.Common/Source/Presentation/MMCA.Common.UI/Theme/MmcaThemeProviders.razor:35). - Walkthrough
ModulePathis the_content/MMCA.Common.UI/theme.jsstatic-web-asset path (line 18), wrapped in a LazyJsModule field (line 19). Two components resolving the same scoped service therefore share one import rather than racing two.IsDarkMode(line 22) andIsInitialized(line 25) are public with a private setter: subscribers read the state, only this class writes it.OnChange(line 28) is a plainEventHandler?.InitializeAsync()(line 34) is idempotent by an early return onIsInitialized(lines 36-39). It imports the module (line 41), asksgetfor the stored value (line 42), and resolves the mode: a stored value wins by an ordinal case-insensitive compare against"dark"(lines 43-44), otherwise it falls back to the OS setting throughsystemPrefersDark(line 45), which readsprefers-color-scheme(theme.js:33-35). Only then does it setIsInitializedand raiseOnChange(lines 47-48), so the first notification carries the resolved answer, not the default.SetDarkModeAsync(bool)(line 54) persists first, throughset(line 59), then writes the field (line 60) and notifies (line 61): a failed import or JS call leavesIsDarkModeand every subscriber on the mode that is actually stored (comment,ThemeService.cs:56-57). The JS side writes a non-HttpOnly cookie with a one-yearmax-ageandsamesite=laxand mirrors it tolocalStorage, guarding the mirror in atrybecause private browsing can refuse storage (theme.js:23-31). The cookie is deliberately readable by the server, which is how SSR can paint the right theme on the first response (theme.js:1-2).ToggleAsync()(line 65) isSetDarkModeAsync(!IsDarkMode), the entire body of the app-bar toggle's click handler.DisposeAsync()(line 70) forwards to the module wrapper, which is where the guarded release of a torn-down circuit'sIJSObjectReferencelives.
- Why it's built this way: ADR-028 (
Website/docs-src/adr/028-dark-theme-mode.md) requires the preference to survive a reload and to be visible to the server for a no-flash first paint, which is why the value goes to a cookie andlocalStoragerather than to component state, and why the service holds noMudThemeof its own: it publishes a boolean and lets the theme providers decide what that means visually. Not gatingInitializeAsynconRendererInfois also deliberate and pinned: the prerender test uses thegetinvocation as proof that the first render ran at all (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Theme/MmcaThemeProvidersPrerenderTests.cs:29-33). - Where it's used: registered by
AddUISharedas scoped (MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:180) and consumed byMmcaThemeProviders, which subscribes inOnInitialized, initializes on first render and unsubscribes on dispose (MMCA.Common/Source/Presentation/MMCA.Common.UI/Theme/MmcaThemeProviders.razor:28,35,119), byThemeToggle(MMCA.Common/Source/Presentation/MMCA.Common.UI/Theme/ThemeToggle.razor:2,7), by the MAUI head'sNativeThemeSync, which mirrors the in-app choice onto the native chrome (MMCA.Common/Source/Presentation/MMCA.Common.UI.Maui/Components/NativeThemeSync.razor:17,41,52), and by the login flow, which applies a returning user's stored theme (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Login.razor:232-234). Hosts that compose their own DI register it directly, for example MMCA.Helpdesk (MMCA.Helpdesk/Source/Hosts/UI/MMCA.Helpdesk.UI.Web/Program.cs:23). Behavior is pinned through the two components that drive it, MmcaThemeProvidersTests and ThemeToggleTests, and end to end byDarkModeE2ETests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/Layout/DarkModeE2ETests.cs:54). - Caveats: no test file in
MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Servicesis named for this type; its behavior is covered only through those components, so a change toInitializeAsyncsurfaces as a component-test failure rather than a direct one.OnChangeis a plain event with no weak-reference handling, so a subscriber that fails to unsubscribe outlives its component for the life of the circuit; both in-framework subscribers unsubscribe on dispose and both have a test asserting it.SetDarkModeAsyncsetsIsDarkModeonly after the JS write succeeds, so a failedsetthrows out of the call with the in-memory mode, the persisted mode and the subscribers still in agreement and noOnChangeraised. The exception is not swallowed here: the callers that must not take down the circuit wrap the call in ThemeInterop (ThemeToggle.razor:22).
ApiUserPreferenceWriter
MMCA.Common.UI ·
MMCA.Common.UI.Services.Preferences·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Preferences/ApiUserPreferenceWriter.cs:22· Level 1 · class (sealed)
- What it is: the default IUserPreferenceWriter. It PUTs the
culture/theme choice to
auth/preferencesthrough the shared"APIClient", and it declines to make the call at all when the request is already known to be doomed. - Depends on: IUserPreferenceWriter (implemented),
ITokenStorageService, JwtTokenInfo and the nested
UserPreferencesRequest. Externals:
IHttpClientFactoryandSystem.Net.Http.Json. - Concept introduced, best-effort writes still have a cost.
[Rubric §13, Observability & Operability]assesses whether the system's own traffic keeps its signals meaningful; the class comment (ApiUserPreferenceWriter.cs:13-18) makes the argument explicitly. Because the caller never learns the write failed, a doomed request cannot help the user and still lands in failed-request telemetry, and at low traffic one 401 per theme or culture toggle is enough on its own to trip a failed-request alert rule. Both guards below therefore exist for the alerting story, not the user's story.[Rubric §11, Security]also touches this: the writer never inspects or forwards the token itself, it only asks whether one is usable. - Walkthrough
- The primary constructor takes
IHttpClientFactoryandITokenStorageService(ApiUserPreferenceWriter.cs:22-24).ExpirySkewis 30 seconds and is documented as matching the token-storage skew so this class agrees with the layer that does the refreshing (ApiUserPreferenceWriter.cs:26-27)._rejectedToken(ApiUserPreferenceWriter.cs:37) holds the token the API last refused, for the lifetime of this scoped writer. SaveAsync(ApiUserPreferenceWriter.cs:40) reads the access token (ApiUserPreferenceWriter.cs:42), then applies guard one:JwtTokenInfo.IsFresh(token, ExpirySkew)(ApiUserPreferenceWriter.cs:47, the helper atMMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/Tokens/JwtTokenInfo.cs:16). The comment (ApiUserPreferenceWriter.cs:44-46) notes thatIsFreshalso covers null and unreadable tokens, which makes this the anonymous-user guard as well.- Guard two compares the current token against
_rejectedTokenwithStringComparison.Ordinal(ApiUserPreferenceWriter.cs:55); the comment (ApiUserPreferenceWriter.cs:52-54) explains why expiry alone is not enough, since a token can be unexpired and still rejected (revoked session, rotated signing key, a user the API now treats as gone). - The call itself is a
PutAsJsonAsyncto the relativeauth/preferences(ApiUserPreferenceWriter.cs:62-66), and a401 Unauthorizedlatches_rejectedToken(ApiUserPreferenceWriter.cs:68-71). - Both
HttpRequestException(ApiUserPreferenceWriter.cs:73) andTaskCanceledException(ApiUserPreferenceWriter.cs:77) are swallowed, each with a comment noting that the cookie already holds the choice for this device.
- The primary constructor takes
- Why it's built this way:
ADR-027 and
ADR-028 make the local cookie the
runtime channel and this write a roaming extra. Storing the rejected token rather than setting a
boolean latch is the deliberate detail (
ApiUserPreferenceWriter.cs:31-36): a fresh sign-in produces a different token, so writing resumes with no reset step and no staleness of its own. - Where it's used: registered with
TryAddScopedin DependencyInjection (MMCA.Common.UI/DependencyInjection.cs:199); resolved optionally by the theme toggle (ThemeToggle.razor:23-27) and the culture switcher (CultureSwitcher.razor:38-42).
IUserPreferenceReader
MMCA.Common.UI ·
MMCA.Common.UI.Services.Preferences·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Preferences/IUserPreferenceReader.cs:9· Level 1 · interface
- What it is: the read half of cross-device preferences. It fetches the signed-in user's stored culture and theme, used at login to reapply a returning user's choices on a new device.
- Depends on: UserPreferences (its return type); implemented by ApiUserPreferenceReader. The write half is IUserPreferenceWriter.
- Concept introduced: nothing new; it mirrors the best-effort contract the writer introduced. The
doc comment (
IUserPreferenceReader.cs:3-8) pins the failure mode: implementations return an emptyUserPreferences(both fields null) for anonymous users or on any error, so a failed read never blocks login.[Rubric §19, State Management & Data Flow]applies, since this is the moment the roaming copy is reconciled against the local one. - Walkthrough
GetAsync(CancellationToken cancellationToken = default)returningTask<UserPreferences>(IUserPreferenceReader.cs:13). There is no failure channel in the signature at all, which is the contract making itself unmistakable.
- Why it's built this way:
ADR-027 and
ADR-028. A
Result<T>here would invite a caller to surface an error the user cannot act on during a login they just completed successfully. - Where it's used: injected by the login page (
Login.razor:14) and read once inApplyStoredPreferencesAndNavigateAsync(Login.razor:228-230), which applies the theme through ThemeService (Login.razor:232-235) and the culture through ICultureApplier (Login.razor:243), skipping the culture round-trip when the stored value already matches the current one (Login.razor:237-238).
MMCATheme
MMCA.Common.UI ·
MMCA.Common.UI.Theme·MMCA.Common/Source/Presentation/MMCA.Common.UI/Theme/MMCATheme.cs:9· Level 2 · class (static)
What it is: the single application-wide MudBlazor
MudThemeinstance, defining the brand palette (light and dark), typography, and layout radius, applied once viaMudThemeProviderin the root layout.Depends on: BrandColors (the palette source of truth); MudBlazor (NuGet:
MudTheme,PaletteLight,PaletteDark,Typography,LayoutProperties).Concept introduced, one theme, accessibility-justified, with contrast math for both text and non-text UI.
[Rubric §20, Design System & Theming]assesses whether an app has a single coherent theme rather than per-page overrides;MMCATheme.Instanceis that one object (line 11).[Rubric §21, Accessibility]is unusually visible here, because most non-default color choices carry inline WCAG 2.1 AA/1.4.11 contrast math in a comment immediately above the value:- light
Warning = BrandColors.LightWarning(#A85D00) /WarningContrastText = BrandColors.LightWarningContrastText(#FFFFFF) (lines 35-36): the palette color is used as text and as a border as often as it is a fill (Color.WarningonMudText/MudLink/MudIconand on outlined chips/buttons), and MudBlazor's default#F57F17is only about 2.65:1 on Surface, failing the 4.5:1 floor everywhere it is text;#A85D00is 4.96:1 on Surface and 4.79:1 on Background, and is dark enough that white becomes the correct on-color label (comment, lines 29-34); - light
LinesInputs = "rgba(0,0,0,0.45)"(line 42): outlined-field borders answer to the 3:1 non-text floor (WCAG 1.4.11); MudBlazor's default is only 3.03:1/3.01:1, passing with no margin, so any host that darkens Background drops it below (comment, lines 38-41); - dark
PrimaryContrastText = "rgba(0,0,0,0.87)"(line 66), because white on the lightened dark-mode primary#42A5F5is about 2.65:1 fill-on-fill while dark text is about 6.6:1 (comment, lines 63-65); - dark
SecondaryContrastText/TertiaryContrastText/InfoContrastText/SuccessContrastText(lines 76, 78, 80, 82): every lightened dark-mode accent takes the same Material treatment, white is 2.36-2.65:1 as a fill across the four colors whilergba(0,0,0,0.87)lands at 6.96-7.70:1, and each accent stays legible as text on Surface too (comment, lines 71-75); - dark
WarningContrastText(line 85), white on#FFA726being about 2.0:1 against about 10.8:1 (line 84); - dark
Error = BrandColors.DarkError(#FF8A80) /ErrorContrastText = "rgba(0,0,0,0.87)"(lines 90, 93): the previous#EF5350was only 3.84:1 as text on Surface, below the 4.5:1 floor whereverColor.Erroris a label rather than a fill (inline validation copy, outlined error chips);#FF8A80reads 5.86:1 on Surface, with the white-vs-dark label question resolved the same way as Primary (comments, lines 86-89, 91-92); - dark
LinesInputs = "rgba(255,255,255,0.5)"(line 98): the dark defaultrgba(255,255,255,0.3)is only 2.6:1 on Surface, effectively invisible to a low-vision user, while0.5is 4.59:1/5.10:1 (comment, lines 94-97); - dark
TableStriped = "rgba(0,0,0,0.2)"/TableHover = "rgba(0,0,0,0.32)"(lines 107-108): the row overlays darken rather than lighten, because a link sits on the composited row and MudBlazor's default white striping turns Surface#27303Ainto aboutrgb(82,89,97), where the link colour#42A5F5is only about 2.66:1 (any white overlay above roughly 3% already drops under 4.5:1); the black overlays read 5.77:1 (striped) and 6.15:1 (hover) on Surface and 6.61:1 / 6.87:1 on Background while staying visibly distinct from the plain row and from each other, andLinkContrastTestspins both (comment, lines 99-106).
The
Secondary(light) contrast rationale is deliberately not repeated here: line 21 points at BrandColors, where the value and its justification live together. The reverse holds forWarningandError: their constants live in BrandColors, whose summaries point back here for the math.- light
Walkthrough: a single
static MudTheme Instance { get; }(line 11) initialized with four blocks. Every hex value in both palettes is a BrandColors constant (comment, lines 15-17); the only literals left are thergba(...)on-color labels and input lines, each justified by the contrast comment above it.PaletteLight(lines 13-55) reads its primary and secondary triads from BrandColors (lines 18-24), sets the semantic colors from theLight*constants (Tertiary,Info,Success,Warning,Error, lines 25-37, including the WCAG-drivenWarningandLinesInputsvalues above), and then fixes app chrome:AppbarBackgroundandDrawerBackgroundfromBrandColors.ChromeBackground,DrawerText/DrawerIconfromChromeTextMuted, and background, surface, text, action and divider values from the matchingLight*constants (lines 43-54).PaletteDark(lines 56-121) lightens the primary for contrast on dark surfaces (Primary = BrandColors.PrimaryLight, line 60), darkens the table row overlays (lines 107-108, above), takes the sameChrome*constants for app bar and drawer so the shell reads identically in both modes (lines 109-115), and darkens the surface stack (Background = BrandColors.DarkBackground,Surface = BrandColors.DarkSurface, lines 111-112) with theDark*text and divider tones.Typography(lines 122-200).Defaultsets the font stackInter, Segoe UI, Helvetica Neue, Arial, sans-serif; the comment above it records that Inter is self-hosted by this RCL (wwwroot/fontsplus an@font-faceblock inwwwroot/app.css) and that before those faces existed the stack silently fell through to Segoe UI, so the two must stay in step.H1throughH4use display weights 800/800/700/700 with slight negative letter spacing, which the comment explains is how Inter is meant to be set at large sizes;H5andH6stay at weight 600 with no negative tracking.Subtitle1/Subtitle2sit at weight 500,Body1/Body2set line heights 1.6 and 1.5, andButtonsetsTextTransform = "none", because MudBlazor's default uppercasing wrecks localized strings (German compounds, accented capitals) and reads dated; weight 600 keeps the label as prominent as the shouting did.LayoutPropertiessetsDefaultBorderRadius = "6px"(lines 201-204).
Why it's built this way: a static get-only property means the theme is constructed once and shared by every
MudThemeProvider. Sourcing every hex value from BrandColors rather than re-typing it is what letsBrandColorTokenTestspolice C# versus CSS drift and keeps this class a mapping of roles to named colors, and the per-color contrast comments turn accessibility decisions into reviewable source rather than tribal knowledge, now covering both text-on-fill (4.5:1) and non-text UI like input borders (3:1, WCAG 1.4.11) rather than text alone. The button-casing override is a small but instructive case of[Rubric §27, Internationalization & Localization]reaching into theming: a purely visual default became a localization problem.Where it's used: applied in the root layout of the Blazor Web and MAUI hosts via
MudThemeProvider Theme="MMCATheme.Instance".
ApiUserPreferenceReader
MMCA.Common.UI ·
MMCA.Common.UI.Services.Preferences·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Preferences/ApiUserPreferenceReader.cs:14· Level 2 · class (sealed)
- What it is: the default IUserPreferenceReader. It GETs
auth/preferenceswith the signed-in user's bearer token and hands back the culture and theme the user chose on some other device, or empty preferences when there is nothing to read. - Depends on: IUserPreferenceReader (the contract,
ApiUserPreferenceReader.cs:16), UserPreferences (the two-nullable-field record it returns, line 18), ITokenStorageService (primary-constructor parameter, line 15), JwtTokenInfo (the freshness check, line 31);IHttpClientFactoryandSystem.Net.Http.Json(BCL) for the named"APIClient". - Concept introduced, a best-effort read that cannot fail its caller.
[Rubric §6, CQRS & Event-Driven Design]assesses whether a secondary concern is prevented from changing the outcome of the primary operation. Applying a stored preference is a nicety attached to login; a network hiccup while reading it must not turn a successful sign-in into an error page. This class encodes that as a type-level promise:GetAsyncreturnsUserPreferencesrather than a Result, and there is no path out of it that reports a failure. That is the posture ADR-096 records for side effects generally (Website/docs-src/adr/096-best-effort-side-effects.md), applied on the read side.[Rubric §26, Front-End Security]also applies, in a small but real way: the class refuses to spend a round trip on a token it can already see is stale. - Walkthrough
- Two statics carry the whole configuration.
Emptyis a single sharednew UserPreferences(null, null)(line 18), so the failure paths allocate nothing, andExpirySkewisTimeSpan.FromSeconds(30)(line 21) with a comment stating why the value is duplicated here: it must agree with the token-storage layer that does the refreshing, or the two would disagree about when a token is still usable. GetAsync(CancellationToken)(line 24) reads the access token (line 26) and gates onJwtTokenInfo.IsFresh(token, ExpirySkew)(line 31). The comment (lines 28-30) spells out the two cases this covers: an expired or unreadable token buys a guaranteed 401, andIsFreshalso covers the anonymous (null) case. Both returnEmpty(line 33).- The request itself is three lines: resolve the named
"APIClient"(line 38), which already carries the bearer andAccept-Languagehandlers (MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:146-147), thenGetFromJsonAsync<UserPreferences>against the relative URIauth/preferences(lines 39-41). - Null-coalescing on the deserialized value (line 42) means a body of literal
nullis the same as no preference. - Two catch blocks,
HttpRequestException(line 44) andTaskCanceledException(line 48), both returnEmpty. Note what is not caught: anything else, including aJsonExceptionfrom a malformed body, still escapes, so a contract break is loud while a transport failure is quiet.
- Two statics carry the whole configuration.
- Why it's built this way: ADR-027 (
Website/docs-src/adr/027-multi-locale-i18n.md) and ADR-028 make the stored culture and theme a per-user server-side value so the choice follows a user between devices, and login is the one moment where reading it is worth a round trip. Catching narrowly and returning a shared empty record is what makes the reconciliation safe to await unconditionally in the login flow. It is the read half of a pair: ApiUserPreferenceWriter is the write half, and the two are registered together (MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:199-200). - Where it's used: registered by
AddUISharedwithTryAddScoped(DependencyInjection.cs:200) and injected by exactly one page, the framework's login page (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Login.razor:14). ItsApplyStoredPreferencesAndNavigateAsynccallsGetAsync, applies a stored theme through ThemeService, and, when the stored culture differs from the current one, hands the rest of the navigation to ICultureApplier, which owns the head-specific switch (Login.razor:228-248). - Caveats: no test under
MMCA.Common/Testsnames this type, while its sibling writer has ApiUserPreferenceWriterTests; the reader's guard and its two catch paths are unpinned. BecauseTaskCanceledExceptionis caught unconditionally, a caller that cancels its own token receivesEmptyrather than anOperationCanceledException, which is the opposite of the convention HttpResultExecutor enforces elsewhere in this package; the single caller passes no token (Login.razor:230), so the difference is invisible in current use.
AbsoluteUrlAttribute
MMCA.Common.UI ·
MMCA.Common.UI.Validation·MMCA.Common/Source/Presentation/MMCA.Common.UI/Validation/AbsoluteUrlAttribute.cs:26· Level 0 · class (sealed,ValidationAttribute)
- What it is: a DataAnnotations rule that a string property must be an absolute
httporhttpsURL. It is the client-side twin of the server'sAbsoluteUrlRulesin MMCA.Common.Application, so a form gives the same verdict the API would (doc comment,AbsoluteUrlAttribute.cs:6-8). - Depends on: nothing first-party. Externals:
System.ComponentModel.DataAnnotations(ValidationAttribute,ValidationResult,ValidationContext,RequiredAttribute), BCLUri. It is consumed by DataAnnotationsModelValidator, which is the thing that actually runs it on a MudBlazor field. - Concept introduced, validation parity as a security control, not just a UX nicety.
[Rubric §24, Forms, Validation & UX Safety]assesses whether the rules a form enforces match the rules the server enforces. Most parity gaps cost only a wasted round trip. This one is different, and the doc comment says why (lines 8-11): the values this rule guards get rendered straight into an imagesrcor a linkhref, so acceptingjavascript:ordata:on the client and rejecting it on the server means the only thing between a pasted script URL and the rendered page is a network hop.[Rubric §26, Front-End Security]assesses browser-side hardening. Restricting the accepted schemes to exactlyhttpandhttps(lines 44-46) is the narrow allowlist that keeps ajavascript:URL out of an anchor target in the first place.- Optionality is the caller's decision. Null, empty, and whitespace all pass (line 39). The attribute deliberately does not imply "required": a mandatory field pairs this with
[Required], which is what keeps a blank required field showing one clear message instead of two (lines 12-16). - The message is a resource key channel.
ErrorMessageis returned unchanged rather than run throughstring.Format(line 52), which is what lets a model declareErrorMessage = "Validation.AbsoluteUrl"; DataAnnotationsModelValidator resolves every message it receives against the page's localizer and passes an unknown key through untouched, so a plain-English message still renders as written (lines 17-23). See ADR-027 for the localization model.
- Walkthrough
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)](line 25): properties only, once each.- The parameterless constructor (lines 29-32) passes the default English message
"The value must be an absolute http or https URL."to theValidationAttributebase, so a model that declares noErrorMessagestill says something useful. IsValid(object?, ValidationContext)(lines 35-53) null-guards the context (line 37), then returnsValidationResult.Successfor anything that is not a non-blank string (lines 39-42). That single guard is what implements the "optional by default" contract above.- The accept path (lines 44-49):
Uri.TryCreate(url, UriKind.Absolute, out Uri? uri)combined with an ordinal scheme comparison againstUri.UriSchemeHttpandUri.UriSchemeHttps.UriKind.Absolutealone is not enough, because plenty of non-web schemes parse as absolute URIs; the scheme equality check is the actual gate. - The reject path (lines 51-52) builds the member-name array from
validationContext.MemberNamewhen one is present and returnsnew ValidationResult(ErrorMessage, members), so MudBlazor can attribute the failure to the right field.
- Why it's built this way: expressing the rule as an attribute means it travels with the model property rather than with a page, so every form that binds that property inherits it, and the same model can be validated on the server by
Validator.TryValidateProperty. Comparing withStringComparison.Ordinalagainst the BCL scheme constants avoids the culture-sensitive comparison trap and matches howUrinormalizes schemes to lowercase. - Where it's used: applied to URL-bearing properties on shared form and request models, and executed by DataAnnotationsModelValidator through the ModelValidation bridge.
- Caveats / not-in-source: the exact set of consumer models carrying this attribute is not visible from this file; the server-side
AbsoluteUrlRulesit mirrors lives in MMCA.Common.Application and is only named in the doc comment (line 7).
IModelValidator
MMCA.Common.UI ·
MMCA.Common.UI.Validation·MMCA.Common/Source/Presentation/MMCA.Common.UI/Validation/IModelValidator.cs:13· Level 0 · interface
- What it is: a one-method contract that validates a single property of a form model and returns that property's error messages. It is the pluggable rule engine behind ModelValidation
.For. - Depends on: nothing. Its in-box implementation is DataAnnotationsModelValidator (named in the doc comment, line 8); its only caller is ModelValidation.
- Concept introduced, the shape MudBlazor forces and the abstraction that exploits it. MudBlazor hands a field's
Validationdelegate two arguments: the form model and the dotted path of the member being edited. That is exactly the shape a rule engine needs, so the interface simply names it:IEnumerable<string> Validate(object model, string propertyPath)(line 27).[Rubric §1, SOLID]assesses dependency direction and interface size. This is a one-method, dependency-free interface, and the doc comment (lines 8-10) states the payoff plainly: a consumer that keeps its rules in FluentValidation supplies its own implementation, so MMCA.Common.UI never has to reference a validation library. The abstraction exists to keep a NuGet dependency out of a shipped UI package, not to satisfy a pattern.[Rubric §24, Forms, Validation & UX Safety]assesses where form rules live. By making the engine pluggable, the framework can offer a default (attributes on the model) without forcing it on a consumer whose rules already live somewhere else.- Two contract details are load-bearing and documented rather than typed. The
propertyPathis dotted and relative to the model, for example"Title"or"Address.City"(lines 19-23), which is what MudBlazor derives from a field'sForexpression. And the return value is never null (line 25): an empty sequence means valid, so no caller has to null-check.
- Walkthrough: a single member,
Validate(object model, string propertyPath)(line 27), returningIEnumerable<string>.objectrather than a generic type parameter is deliberate: MudBlazor'sValidationparameter is itself untyped at that position, so a generic interface would only add a cast at the boundary. - Why it's built this way: the smallest possible extension point that still matches the host framework's calling convention. Anything wider (a "validate the whole model" method, a result type) would be unused by the one thing that calls it.
- Where it's used: accepted by ModelValidation
.For(ModelValidation.cs:43), which wraps it in the delegate a MudBlazor field'sValidationparameter expects; implemented by DataAnnotationsModelValidator.
BlazorCircuitLimitSettings
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web.Hardening·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Hardening/BlazorCircuitLimitSettings.cs:17· Level 0 · class (sealed)
- What it is: the bound options type for a Blazor Server host's circuit hardening, three numbers under the
BlazorCircuitLimitsconfiguration section: a ceiling on concurrently active circuits, a ceiling on disconnected circuits retained for reconnect, and how long a disconnected circuit is retained. - Depends on: nothing first-party;
System.ComponentModel.DataAnnotations.RangeAttributeon all three properties (BlazorCircuitLimitSettings.cs:37,:45,:56) is what makes the settings self-validating. The class doc (:9-15) states it is deliberately separate from UiRateLimitingSettings: the rate limiter bounds how fast requests arrive, while a circuit is state that stays resident on the server for as long as the connection lives, so a caller opening circuits slowly enough to stay inside the rate window still accumulates them without this ceiling. - Concept introduced, sizing an abuse ceiling from the container rather than from traffic. The doc comment on
MaxActiveCircuits(:26-36) derives 200 from the container a Blazor UI host typically runs in (0.25 vCPU, 0.5 GiB): roughly 200 MiB of that half-gibibyte is the runtime, the framework and the static assets the host also serves, leaving on the order of 300 MiB for circuit state, and a MudBlazor page's server-side render tree costs a few hundred kilobytes up to about a megabyte, so 200 fits with headroom on memory and is already generous on the 0.25 vCPU side, where render throughput binds first. It sits far above real demand for a host rendering Interactive Auto, where a returning visitor's session moves to the WebAssembly runtime after the first render and holds no circuit at all: the number is an abuse ceiling, not a capacity plan. [Rubric §29, Resilience & Business Continuity] assesses exactly this: a documented, container-derived ceiling rather than an arbitrary round number. [Rubric §11, Validation] applies through the[Range]attributes that fail startup on an out-of-range value (see BlazorCircuitLimitExtensions). - Walkthrough
SectionName => "BlazorCircuitLimits"(:20) is the configuration section this type binds from.MaxActiveCircuits(:37-38),[Range(1, 100_000)], defaults to200.DisconnectedCircuitMaxRetained(:45-46),[Range(0, 10_000)], defaults to25, tighter than the Blazor framework default of 100 because a retained circuit holds the same state an active one does while serving nobody (:41-43).DisconnectedCircuitRetentionSeconds(:56-57),[Range(5, 3600)], defaults to60, tighter than the framework's three minutes: a visitor who really did drop off Wi-Fi reconnects within seconds, and everything beyond that is memory held for someone who is gone (:49-54). The doc comment says a host whose audience sits on a flaky shared network (a conference venue, where someone walking between rooms should come back to the state they left) raises this back towards the framework's 180 and leans onDisconnectedCircuitMaxRetainedto bound the memory.
- Why it's built this way: the three numbers are kept in one bound, validated options type instead of inline literals so the ceiling can be tuned per environment through configuration without a code change, and so an out-of-range value fails fast at startup via BlazorCircuitLimitExtensions's
ValidateOnStart()rather than silently at runtime. See ADR-088 for the edge-hardening posture this settings type is part of. - Where it's used: bound and registered by BlazorCircuitLimitExtensions's
AddBoundedBlazorCircuits(); read by BoundedCircuitHandler for the active-circuit ceiling and byBlazorCircuitLimitExtensions.RetentionFromfor the disconnected-circuit settings.
OptionalEmailAttribute
MMCA.Common.UI ·
MMCA.Common.UI.Validation·MMCA.Common/Source/Presentation/MMCA.Common.UI/Validation/OptionalEmailAttribute.cs:25· Level 0 · class (sealed,ValidationAttribute)
- What it is: a DataAnnotations rule that a string property, when it has a value, must look like an email address. A blank value passes, so the attribute never makes a field required.
- Depends on: nothing first-party. Externals:
System.ComponentModel.DataAnnotations(ValidationAttribute,ValidationResult,ValidationContext). Like its sibling AbsoluteUrlAttribute, it is run on a MudBlazor field by DataAnnotationsModelValidator through the ModelValidation bridge. - Concept introduced, a deliberately loose format check that mirrors the server's rule.
[Rubric §24, Forms, Validation & UX Safety]assesses whether the client enforces what the server enforces. The privateIsValidEmailFormat(OptionalEmailAttribute.cs:59-65) accepts exactly one@with at least one character on each side of it (index > 0,index != value.Length - 1,index == value.LastIndexOf('@')). Its doc comment (:52-56) names this as the server's email format rule, so the form does not reject an address the API would accept, nor accept one the API would refuse.- Optionality is the caller's decision. A non-string, null, empty or whitespace value returns
ValidationResult.Success(:38-41), the same contract asAbsoluteUrlAttribute: a mandatory field pairs this with[Required].
- Optionality is the caller's decision. A non-string, null, empty or whitespace value returns
- Walkthrough
- The parameterless constructor (
:28-31) passes the default English message"Enter a valid email address."to the base class. IsValid(object?, ValidationContext)(:34-50) null-guards the context (:36), lets blanks through, then callsIsValidEmailFormat. On failure it builds the member-name array fromvalidationContext.MemberNamewhen present and returnsnew ValidationResult(ErrorMessage, members)(:48-49), so the failure attaches to the right field.IsValidEmailFormat(string)(:59-65):IndexOf('@', StringComparison.Ordinal)plus the three index checks above, with no regular expression.
- The parameterless constructor (
- Why it's built this way: the check is a plain index test, not an RFC parser, so the client verdict cannot drift from the server's equally simple rule, and the ordinal comparison avoids culture sensitivity.
- Where it's used: exercised by
OptionalEmailAttributeTestsin MMCA.Common.UI.Tests (Validation/OptionalEmailAttributeTests.cs). - Caveats / not-in-source: which consumer models carry this attribute is not visible from this file.
UiRateLimitingSettings
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web.Hardening·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Hardening/UiRateLimitingSettings.cs:33· Level 0 · class (sealed)
- What it is: the bound options type for a Blazor Web host's own edge rate limiting, under the
UiRateLimitingconfiguration section: an on/off switch, a per-client-IP request budget and window, and a replica-wide concurrency ceiling. - Depends on: nothing first-party;
RangeAttributeonPermitLimit,WindowSeconds, andGlobalConcurrencyLimit(UiRateLimitingSettings.cs:57,:61,:75). - Concept introduced, a Blazor host's own edge limiter because it is a separate origin from the Gateway. The class doc (
:9-32) states why a UI host needs one at all: the Blazor UI is a separate externally reachable origin from the Gateway (its own FQDN), so the ADR-088 Gateway edge limiter never sees a request to it, and every page load on this origin opens an interactive Server circuit against a typically 0.25 vCPU / 0.5 GiB container. It is shaped like the Gateway's limiter (same per-IP-plus-global-concurrency pairing, anonymous traffic included) but shipped as a distinct kit rather than referenced fromMMCA.Common.Gateway, because a Blazor host is not a reverse proxy and its exemptions differ (it serves/_frameworkand/_contentitself). Both counts are per replica, in memory, the same deliberate trade the Gateway kit makes: an edge limiter answers in microseconds on every request, and a shared counter would put a network round trip in front of the whole site. [Rubric §29, Resilience & Business Continuity] and [Rubric §11, Validation] apply as with BlazorCircuitLimitSettings: a documented, traffic-shape-derived number behind[Range]validation. - Walkthrough
SectionName => "UiRateLimiting"(:36).Enabled(:43), defaulttrue; the doc comment (:39-42) calls out its escape hatch use, a load or capacity proof driven from one runner IP that the per-IP window cannot tell from a flood.PermitLimit(:57-58),[Range(1, 1_000_000)], default300, which no real visitor approaches (a page load costs a handful of requests once static assets are exempt, and Interactive Auto hands the session to WebAssembly after the first render). The doc comment (:48-56) sets it well above a single visitor on purpose because an office or mobile-carrier NAT presents many visitors as one IP, and tells a host whose audience sits behind one address (a venue's wifi, a single corporate egress) to raise it, since the whole crowd arrives as a single partition key.WindowSeconds(:61-62),[Range(1, 3600)], default60.GlobalConcurrencyLimit(:75-76),[Range(1, 1_000_000)], default200: a ceiling rather than a rate, guarding against a slow downstream backing up threads on this replica; excess requests are rejected with 429 immediately rather than queued (:64-74), and unlike the per-IP window it needs no widening for a shared-address audience because in-flight concurrency is bounded by what the container can render, not by how many people share an address. The doc comment (:71-73) also records that the Blazor circuit transport (/_blazor) is excluded from this ceiling: a circuit WebSocket would hold one permit for its whole lifetime, so open circuits are bounded byBlazorCircuitLimits:MaxActiveCircuits(BlazorCircuitLimitSettings) instead.
- Why it's built this way: same rationale as BlazorCircuitLimitSettings, a bound, validated, environment-tunable options type rather than inline literals in UiRateLimitingExtensions. See ADR-088 and ADR-124 for the split between the request concurrency ceiling and the circuit ceiling.
- Where it's used: bound and read by UiRateLimitingExtensions's
AddUiRateLimiting(), which closes over a resolved instance rather thanIOptions<T>per request (see that section).
DataAnnotationsModelValidator
MMCA.Common.UI ·
MMCA.Common.UI.Validation·MMCA.Common/Source/Presentation/MMCA.Common.UI/Validation/DataAnnotationsModelValidator.cs:21· Level 1 · class (sealed)
- What it is: the in-box IModelValidator, backed by
System.ComponentModel.DataAnnotations. It validates one property against the attributes declared on the model and localizes every message it produces, so the rules a shared request or form model already carries are the only place those rules are written and markup stops repeatingRequired/MaxLengthper field. - Depends on: first-party: IModelValidator (the contract it implements, line 21), and it executes rules such as AbsoluteUrlAttribute. Externals:
System.ComponentModel.DataAnnotations(Validator,ValidationContext,ValidationResult),System.Reflection(PropertyInfo,BindingFlags,AmbiguousMatchException),Microsoft.Extensions.Localization(IStringLocalizer,LocalizedString). - Concept introduced, message-as-resource-key with pass-through fallback.
[Rubric §27, Internationalization & Localization]assesses whether user-facing text resolves per culture from one catalog. Every message this validator produces is looked up against the injectedIStringLocalizer(line 149) and returned as the raw message whenlocalized.ResourceNotFound(line 150). That single line is what makes the design safe to adopt incrementally: a model can declareErrorMessage = "Some.Resource.Key"and get a localized string, or declare plain English and get plain English, with no flag to set. See ADR-027 for the localization model this plugs into.[Rubric §24, Forms, Validation & UX Safety]assesses single-declaration rules; the model's attributes are the declaration and this class is the only executor.- Reflection that fails silently, on purpose.
TryResolveOwnerreturns false when a link in a dotted path is null or the member does not exist (lines 95-98, 106-110). The comment (lines 77-79) gives the reasoning: an unreachable path carries no rules, so it cannot fail, and a partially built model never throws mid-keystroke. A validator that threw while the user was typing would be worse than one that says nothing. - A real reflection edge case is handled rather than ignored.
FindProperty(lines 118-129) catchesAmbiguousMatchExceptionand retries withBindingFlags.DeclaredOnly, because anew-shadowed property matches twice underFlattenHierarchy; the comment (line 126) records the tie-break rule, the most-derived declaration is the one bound in markup.
- Walkthrough
PropertyLookup(lines 23-24) is the sharedBindingFlagsset:Public | Instance | FlattenHierarchy.- The constructor (lines 36-41) requires an
IStringLocalizerand null-guards it. The parameter doc (lines 31-35) tells callers to pass the page's ownIStringLocalizer<TResource>precisely so that unknown keys fall through unchanged. Validate(object model, string propertyPath)(lines 44-55) is the IModelValidator implementation: guard, resolve the owner andPropertyInfo, return an empty array when unresolvable (line 51), then validate the value the model currently holds viaproperty.GetValue(owner)(line 54).ValidateValue(object model, string propertyPath, object? value)(lines 66-74) is the sibling used when the candidate value has not been written to the model yet, which is the case for the single-field bridge ModelValidation.ForProperty.TryResolveOwner(lines 81-116) isinternal staticso ModelValidation.IsRequiredcan reuse it (ModelValidation.cs:97). It splits the path on.withRemoveEmptyEntries(line 90) and walks segment by segment, returning the last segment'sPropertyInfoplus the object that declares it (lines 100-104).[NotNullWhen(true)]on bothoutparameters (lines 84-85) is what lets callers dereference them without a null check after a true return.ValidateResolved(lines 131-140) builds aValidationContext(owner) { MemberName = property.Name }, callsValidator.TryValidateProperty(line 135), and projects the results throughLocalize, dropping empties (lines 137-139).Localize(lines 142-151) is the resource-key resolution described above.
- Why it's built this way: reusing the BCL validator rather than writing a rule interpreter means every DataAnnotations attribute (in-box or custom, such as AbsoluteUrlAttribute) works with no registration. Splitting
ValidatefromValidateValueis the difference between "check what the model holds" and "check what the user just typed", and both are needed because MudBlazor's two binding styles deliver the value at different times. - Where it's used: constructed inline on a page over that page's localizer and handed to ModelValidation
.For, exactly as NotificationSend does (NotificationSend.razor.cs:77).
BoundedCircuitHandler
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web.Hardening·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Hardening/BoundedCircuitHandler.cs:43· Level 1 · class (sealed, partial,CircuitHandler)
- What it is: a Blazor
CircuitHandlerthat refuses a new circuit once this replica already holds BlazorCircuitLimitSettings.MaxActiveCircuitsactive circuits, and counts circuits back down as they close. - Depends on:
CircuitHandler(base class),IOptions<BlazorCircuitLimitSettings>andILogger<BoundedCircuitHandler>(primary-constructor parameters,BoundedCircuitHandler.cs:43-45). - Concept introduced, a singleton counter guarding a per-circuit-scoped hook. The class doc (
:9-40) states why a handler rather thanCircuitOptions:DisconnectedCircuitMaxRetainedbounds only circuits that already dropped their connection, and since every page load on a public Blazor origin opens one (a host rendering Interactive Auto makes the first render a Server circuit), the handler is the documented extension point that sees a circuit being opened and closed. It also states why the class throws rather than returning a refusal value:OnCircuitOpenedAsyncreturnsTaskwith no "refuse" return, so an exception is the only way to stop a circuit starting, one of the few places in the framework where the Result pattern does not apply because the contract belongs to ASP.NET Core. Finally (:31-39) it explains the one piece of per-circuit state on a singleton: the set of admitted circuits, keyed by reference, which exists because a refused circuit is still torn down throughOnCircuitClosedAsync, and without the set that close would release a permit some other circuit holds. [Rubric §29, Resilience & Business Continuity] assesses this directly: an explicit admission-control decision at the point circuits are opened, rather than letting an unbounded flood of circuits exhaust a replica's memory. [Rubric §14, Testability] applies throughActiveCircuits(:55), a public read-only counter the doc comment (:51-54) says exists so a consuming host can assert the ceiling behaves and a diagnostic endpoint can report saturation;BoundedCircuitHandlerTestsreads it to assert the increment, decrement and floor behavior without inspecting private state. - Walkthrough
_admitted(:47): aConcurrentDictionary<Circuit, byte>constructed withReferenceEqualityComparer.Instance, used as a set of the circuits this handler admitted._activeCircuits(:49) andActiveCircuits => Volatile.Read(ref _activeCircuits)(:55): the live count, read withVolatile.Readbecause it is written fromInterlockedcalls on possibly-concurrent circuit-open/close callbacks.Order => int.MaxValue(:61): runs LAST among registered handlers on the way in, so a refusal happens after cheaper handlers have already done their work rather than in the middle of it (:57-60).OnCircuitOpenedAsync(:64-85): guardscircuitagainst null (:66), then increments first and rolls back on refusal (:70-75) rather than checking-then-incrementing, because two simultaneous opens could otherwise both observe the last free slot and both take it; over the ceiling it decrements, logs viaLogCircuitRefused, and returns a faultedTaskcarrying anInvalidOperationExceptiontelling the caller to retry (:75-80). Only an admitted circuit is added to_admitted(:83).OnCircuitClosedAsync(:88-109): guardscircuitagainst null (:90), then returns without touching the count unless_admitted.TryRemovefinds the circuit (:92-98), because the framework's teardown calls every handler's close unconditionally, including for a circuit this handler refused, and that close must not release a permit another circuit holds. An admitted circuit decrements the count, which then floors at zero (:100-106) as a last line of defence against a count driven negative handing out permits forever.LogCircuitRefused(:111-114): a[LoggerMessage]-generated warning logger, partial method paired with thepartial classdeclaration.
- Why it's built this way: see BlazorCircuitLimitSettings for why 200 is the number; this handler is the enforcement point for that ceiling. See ADR-088 and ADR-124.
- Where it's used: registered as a singleton
CircuitHandlerby BlazorCircuitLimitExtensions'sAddBoundedBlazorCircuits(), called from MMCA.ADC.UI.Web's Blazor Server host composition (Program.cs).
UiRateLimitingExtensions
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web.Hardening·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Hardening/UiRateLimitingExtensions.cs:25· Level 1 · class (static)
- What it is: the registration and pipeline wiring for a Blazor Web host's edge rate limiter, an
IServiceCollectionextension that builds a chained per-IP-and-global limiter from UiRateLimitingSettings, and anIApplicationBuilderextension that adds it to the pipeline. - Depends on: UiRateLimitingSettings;
System.Threading.RateLimiting(RateLimitPartition,FixedWindowRateLimiterOptions,ConcurrencyLimiterOptions,PartitionedRateLimiter); ASP.NET Core'sIServiceCollection/AddRateLimiterandIApplicationBuilder/UseRateLimiter. The class carries a[SuppressMessage("Naming", "CA1708", ...)]attribute (:18-21) whose justification records why: with multipleextension(T)blocks in one static class, CA1708 flags the compiler-generated grouping members as case-colliding even though no user-visible identifier differs only by case. - Concept introduced, exempting probes and static assets from a per-IP window, and long-lived connections from the concurrency ceiling.
IsExempt(PathString, PathString proxyPathPrefix)(:82-94) matches two things: a fixed prefix list (/health,/alive,/_framework,/_content,/hubs,:56) plus the proxied hub path{proxyPathPrefix}/hubs(:85), all matched on whole segments so/healthzis not caught by/health; and any path OUTSIDE the proxy prefix whose last segment carries a file extension (:90-93). Proxied traffic is API traffic whatever its last segment looks like, so/api/report.csvis counted. The doc comment (:44-55) explains the/hubsentry mirrors the Gateway's ownGatewayRateLimiting:BypassPathPrefixesbypass for long-lived SignalR traffic, so a host that ever fronts a hub on this origin is covered by declaration rather than by accident, and that the same hub traffic arriving through the same-origin API proxy (SameOriginApiProxyEndpoint) is exempt at whatever prefix the host configured, not a hard-coded/api/hubs: without it every open hub WebSocket would hold a concurrency lease for its whole lifetime and exhaust the ceiling./_blazoris treated differently on each limiter (:72-76): it has no extension and no exemption from the per-IP window, because the negotiate endpoint is exactly what opens a circuit, butBlazorTransportPrefix(:36-42) keeps it out of the concurrency ceiling, because the same prefix carries the circuit WebSocket, whose lease would otherwise be held for the whole circuit lifetime; open circuits are bounded by BlazorCircuitLimitSettings and BoundedCircuitHandler instead. [Rubric §29, Resilience & Business Continuity] applies to the chained limiter design; [Rubric §1, SOLID] applies toIsExemptbeing the single decision both partitions delegate to, so the shared exemption rule cannot drift between the two limiters, with the concurrency partition adding only the transport prefix on top. - Walkthrough
ExemptPartitionKey,UnknownIpPartitionKey,ConcurrencyPartitionKey(:28,:31,:34): the three partition keys the limiter buckets requests into.BlazorTransportPrefix(:42):"/_blazor", the Blazor circuit transport (negotiate, the circuit WebSocket and its long-polling fallback), excluded from the concurrency ceiling only.ExemptPrefixes(:56) andIsExempt(PathString, PathString)(:82-94): the shared exemption rule, detailed above; internal so it is unit-testable viaInternalsVisibleTo(:77-80).ClientIpPartition(HttpContext, UiRateLimitingSettings, PathString)(:105-134): no limiter for exempt paths, no limiter for an unresolvable client IP (fail open,:118-124, so an in-processTestServeris never collapsed into one shared bucket), otherwise aFixedWindowRateLimiterkeyed on the client IP withQueueLimit = 0(rejected immediately, not queued,:126-133).ConcurrencyPartition(HttpContext, UiRateLimitingSettings, PathString)(:147-164): the replica-wideConcurrencyLimiter, one bucket for the whole process withQueueLimit = 0, exempt for every pathIsExemptmatches plus any path underBlazorTransportPrefix(:155-156).AddUiRateLimiting(IConfiguration)(:175-216), anIServiceCollectionextension: binds and validatesUiRateLimitingSettings(:180-183), then resolves a plain instance from configuration to close over in the partition callbacks (:188-189) rather than resolvingIOptions<T>per request, because the partition callback runs on the hot path of every request and an out-of-range value has already failedValidateOnStart().RejectionStatusCodeis set to 429 in its ownAddRateLimitercall (:191); whenEnabledis false the method returns early with noGlobalLimiterconfigured (:193-196). Otherwise it configuresRateLimiterOptionsthroughConfigure<IOptions<SameOriginApiProxySettings>>(:201-213), reading the proxy'sPathPrefixfrom the proxy's own options (:204) so the limiter and the proxy cannot disagree, and chains the client-IP and concurrency partitions withPartitionedRateLimiter.CreateChained(:208-212), so a request must satisfy both.UseUiRateLimiting()(:225-229), anIApplicationBuilderextension: calls the framework'sUseRateLimiter().
- Why it's built this way: see UiRateLimitingSettings for the tuning rationale. See ADR-088 for the edge-hardening split between this host and the Gateway, ADR-124 for why circuits are bounded by their own ceiling rather than by this limiter, and ADR-131 for the same-origin proxy behind the proxied-hub exemption.
- Where it's used:
AddUiRateLimitingandUseUiRateLimitingare called from MMCA.ADC.UI.Web's Blazor Server host composition (Program.cs); the extension methods live on theIServiceCollection/IApplicationBuilderextension blocks (:166,:219).
LocalizedDataAnnotationsValidator
MMCA.Common.UI ·
MMCA.Common.UI.Validation·MMCA.Common/Source/Presentation/MMCA.Common.UI/Validation/LocalizedDataAnnotationsValidator.cs:21· Level 2 · class (sealed,ComponentBase,IDisposable)
- What it is: a drop-in replacement for the stock
<DataAnnotationsValidator />inside anEditForm. It runs the same DataAnnotations rules, but through DataAnnotationsModelValidator, so everyErrorMessageis resolved as a resource key and fields bound withForshow the translated message; a message that is not a known key passes through unchanged, exactly as with the stock validator (doc comment,LocalizedDataAnnotationsValidator.cs:9-15). - Depends on: DataAnnotationsModelValidator (the localizing rule engine it constructs, line 54) and SharedResource (the resource marker behind the injected
IStringLocalizer<SharedResource>, lines 30-31). Externals:Microsoft.AspNetCore.Components(ComponentBase,[CascadingParameter],[Inject]),Microsoft.AspNetCore.Components.Forms(EditContext,ValidationMessageStore,FieldIdentifier,FieldChangedEventArgs,ValidationRequestedEventArgs),Microsoft.Extensions.Localization,System.Reflection. - Concept introduced, one localizing path for both form styles.
[Rubric §27, Internationalization & Localization]assesses whether user-facing text, validation messages included, resolves per culture from one catalog. The MudForm pages already localize through ModelValidation and DataAnnotationsModelValidator; theEditFormpages used the stock validator, which rendersErrorMessageverbatim, so a model declaring a resource key would show the key. This component closes that gap by feeding the same engine into theEditContextinstead of a MudBlazorValidationdelegate (doc comment, lines 11-14). See ADR-027 for the localization model.[Rubric §24, Forms, Validation & UX Safety]assesses single-declaration rules and consistent feedback timing. The rules stay on the model; this class only decides when they run: per field on change, and for every public property on submit (doc comment, lines 16-19).
- Walkthrough
- State is three nullable fields, the attached
EditContext, itsValidationMessageStore, and the validator (lines 23-25).CurrentEditContextis a private[CascadingParameter](lines 27-28) and the localizer a private[Inject](lines 30-31), so the markup takes no parameters at all:<LocalizedDataAnnotationsValidator />. OnParametersSet(lines 37-57) throwsInvalidOperationExceptionwhen no cascadingEditContextexists, naming the fix ("Place it inside an EditForm", lines 39-43). It returns early when the context is the one already attached (lines 45-48); otherwise it detaches from any previous context (line 50), then creates the message store and anew DataAnnotationsModelValidator(Localizer)and subscribes toOnFieldChangedandOnValidationRequested(lines 52-56). Re-attaching on a swapped context is what keeps a form that replaces its model working.HandleFieldChanged(lines 59-70) clears the changed field's messages, adds_validator.Validate(field.Model, field.FieldName), and callsNotifyValidationStateChanged(lines 66-69).HandleValidationRequested(lines 72-94) clears every message (line 79), reflects the model's public instance properties, drops indexers, and de-duplicates names ordinally (lines 82-86), then validates each one into its ownFieldIdentifier(lines 88-91) before notifying once (line 93).Detach(lines 96-109) unsubscribes both handlers, clears the store and nulls all three fields;Disposeis exactlyDetach()(line 34), so the component never leaves a handler on a context that outlives it.
- State is three nullable fields, the attached
- Why it's built this way: reusing DataAnnotationsModelValidator rather than re-implementing resolution means the
EditFormand MudForm paths cannot disagree about which keys resolve. InjectingIStringLocalizer<SharedResource>rather than taking a page localizer as a parameter keeps the drop-in property: swapping the tag is the whole migration, and the auth forms'Auth.Field.*keys live in that shared catalog (MMCA.Common/CHANGELOG.md:36). - Where it's used: the four auth
EditFormpages, Login (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Login.razor:40), Register (Register.razor:33), ForgotPassword (ForgotPassword.razor:39) and ResetPassword (ResetPassword.razor:43); their models, LoginModel, RegisterModel, ForgotPasswordModel and ResetPasswordModel, name it in their doc comments as the reason theirErrorMessagevalues are keys (LoginModel.cs:10,RegisterModel.cs:11,ForgotPasswordModel.cs:10,ResetPasswordModel.cs:12). It is public API of the package (PublicAPI.Shipped.txt:1193-1195). - Caveats: submit validation walks top-level properties only and calls the per-property path, so it does not run class-level attributes or
IValidatableObject.Validate, which the stock validator's whole-object pass does; a model relying on either loses that check when it switches. Messages always resolve againstSharedResource, so a key that exists only in a page's own resource file passes through untranslated. No file underMMCA.Common/Testsnames this type, so its attach, re-attach and submit behavior is unpinned except through whatever the auth-page E2E runs exercise.
ModelValidation
MMCA.Common.UI ·
MMCA.Common.UI.Validation·MMCA.Common/Source/Presentation/MMCA.Common.UI/Validation/ModelValidation.cs:26· Level 2 · class (static)
- What it is: the bridge that turns a form model's declared rules into the delegate MudBlazor's field
Validationparameter expects, so a page declares its rules once (on the model) instead of scatteringRequiredandMaxLengthacross the markup and re-checking them by hand. - Depends on: first-party: IModelValidator (the pluggable engine taken by
For) and DataAnnotationsModelValidator (taken concretely byForProperty, and reused via itsinternal staticTryResolveOwnerinIsRequired, line 97). Externals:System.Linq.Expressions(Expression<Func<,>>,MemberExpression,UnaryExpression,ParameterExpression),System.ComponentModel.DataAnnotations(RequiredAttribute),System.Reflection(PropertyInfo). - Concept introduced, adapting a model's rules onto a UI library's callback shape.
[Rubric §24, Forms, Validation & UX Safety]assesses whether validation is declared once and enforced consistently. MudBlazor's contract is a delegate; DataAnnotations' contract is attributes on a type. This class is the two-line adapter between them, and the usage block in the doc comment (lines 14-24) is the canonical example a page copies:_validate = ModelValidation.For(_model, new DataAnnotationsModelValidator(L))inOnInitialized, thenValidation="@_validate"on every field.- One delegate serves the whole form.
Forreturns a closure that ignores nothing and dispatches on the path MudBlazor passes (line 48), so a fifteen-field form still assigns the same single delegate to every field. The fallbackinstance ?? modelon that line is the small robustness detail: MudBlazor normally passes its ownMudForm.Modelback, and the captured instance covers the case where it does not, so a field still validates outside a form (parameter doc, lines 33-36). [Rubric §15, Best Practices & Code Quality]assesses rename safety.ForPropertynames the property by expression rather than by string (line 71), so a rename becomes a compile error instead of a silently dead rule.[Rubric §21, Accessibility]:IsRequiredexists so a field'sRequiredparameter (the asterisk and thearia-requiredaffordance) can be read off the same model that supplies the rules, rather than being typed a second time in markup where it can drift from the rule (doc, lines 83-87). It is explicitly not a second rule: MudBlazor's own required message is unused when aValidationdelegate is present, so the localized message from the model is the one shown.
- One delegate serves the whole form.
- Walkthrough
For(object model, IModelValidator validator)(lines 43-49): null-guards both arguments, then returns(instance, propertyPath) => validator.Validate(instance ?? model, propertyPath)(line 48). This is the model-wide bridge, and it is what almost every page wants.ForProperty<TModel, TValue>(TModel model, Expression<Func<TModel, TValue>> property, DataAnnotationsModelValidator validator)(lines 69-81): resolves the dotted path once at setup viaGetPropertyPath(line 79) and returnsvalue => validator.ValidateValue(model, path, value)(line 80). Note the parameter type is the concrete validator, not the interface: the doc (lines 55-58) says why, the value being validated has not necessarily been written to the model yet, so the rules must come from DataAnnotations directly rather than from an arbitrary engine reading the model's current state.IsRequired(object model, string propertyPath)(lines 92-99): resolves the property through DataAnnotationsModelValidator.TryResolveOwnerand reportsproperty.IsDefined(typeof(RequiredAttribute), inherit: true)(line 98).GetPropertyPath<TModel, TValue>(lines 109-133) renders an expression as the dotted path MudBlazor'sForwould produce. It first unwraps theConvertnode the compiler inserts whenTValueis a value type boxed to object (lines 114-116, with the comment saying so), then walksMemberExpressionlinks pushing each name onto aStack<string>(lines 118-123), which reversesm => m.Address.CityintoAddress.Cityonstring.Join(line 132). Anything that is not a chain of property accesses rooted at the lambda parameter throwsArgumentExceptionwith a message showing the expected shape (lines 125-130).
- Why it's built this way: a static class with no state, because the bridge is pure translation. Offering both a model-wide and a single-field entry point matches the two ways MudBlazor fields actually bind, and pushing the expensive part (expression parsing) into setup rather than into the per-keystroke delegate keeps validation cheap on the typing path.
- Where it's used: NotificationSend builds its
_validatedelegate withFor(NotificationSend.razor.cs:77) and reads its twoRequiredaffordances withIsRequired(NotificationSend.razor:47,58). It is public API ofMMCA.Common.UI, so consumer app forms use the same bridge.
BlazorCircuitLimitExtensions
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web.Hardening·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Hardening/BlazorCircuitLimitExtensions.cs:19· Level 2 · class (static)
- What it is: the registration wiring for BlazorCircuitLimitSettings and BoundedCircuitHandler: an
IServiceCollectionextension that registers the bounded-circuit handler, and a factory method that builds theCircuitOptionsretention callback from configuration. - Depends on: BlazorCircuitLimitSettings, BoundedCircuitHandler; ASP.NET Core's
IServiceCollection,IConfiguration, and Blazor'sCircuitOptions/CircuitHandler. - Concept introduced, a singleton handler counting a state that is really per-circuit. The class doc (
:12-17) states why the two halves are deliberately separate calls: the disconnected-circuit retention isCircuitOptionsonAddInteractiveServerComponents, while the active-circuit ceiling is aCircuitHandlersingleton in the container, and both read the same section so the two numbers cannot drift apart.AddBoundedBlazorCircuits's own doc (:45-49) states why registration isAddSingletonand not scoped: circuit handlers are resolved from each circuit's own scope, so a scoped registration would count to one and cap nothing. [Rubric §29, Resilience & Business Continuity] applies to the retention tightening described under BlazorCircuitLimitSettings; [Rubric §1, SOLID] applies to keeping registration wiring separate from both the settings type and the handler's own logic. - Walkthrough
RetentionFrom(IConfiguration)(:28-41): reads BlazorCircuitLimitSettings from configuration (falling back tonew BlazorCircuitLimitSettings()if the section is absent), and returns anAction<CircuitOptions>that copiesDisconnectedCircuitMaxRetainedand convertsDisconnectedCircuitRetentionSecondsto aTimeSpanforDisconnectedCircuitRetentionPeriod(:37-39).AddBoundedBlazorCircuits()(:51-61), anIServiceCollectionextension: binds and validates BlazorCircuitLimitSettings (:55-58), then registers BoundedCircuitHandler as a singletonCircuitHandler(:60).
- Why it's built this way: keeps circuit-limit registration next to the retention-callback factory, both consumers of the same BlazorCircuitLimitSettings, while the enforcement logic itself stays in BoundedCircuitHandler. See ADR-088.
- Where it's used:
AddBoundedBlazorCircuits()andRetentionFromare both called from MMCA.ADC.UI.Web's Blazor Server host composition (Program.cs).
HandoffBody
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web.SameOriginProxy·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SessionHandoffEndpoints.cs:64· Level 0 · record
- What it is: the JSON body both session-handoff endpoints exchange,
{ "handoff": "..." }: a single nullableHandoffstring (SessionHandoffEndpoints.cs:63-64). It is a nestedinternal sealed recordofSessionHandoffEndpoints. - Depends on: nothing first-party.
- Concept: a wire DTO for an opaque, Data-Protection-sealed token; the sealing is taught on
SessionHandoffProtector. - Walkthrough: the token endpoint returns it with a protected access token (
SessionHandoffEndpoints.cs:34); the cookie endpoint binds it from the request body and unprotectsbody.Handoff(SessionHandoffEndpoints.cs:42,49). - Why it's built this way: the property is nullable so a missing or empty body binds instead of failing model binding; the protector then treats null or whitespace as "no handoff" (
SessionHandoffProtector.cs:59-62) and the endpoint answers 400invalid_handoff(SessionHandoffEndpoints.cs:50-53). - Where it's used: only inside
SessionHandoffEndpoints.cs.
ProxyResponseMode
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web.SameOriginProxy·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SameOriginProxyTransformer.cs:14· Level 0 · enum
- What it is: how the same-origin proxy treats the upstream response of one forwarded request:
Forward(copy as is),TokenIssuing(a sign-in: move the token pair into the session cookies and strip it from the body),Revoke(a sign-out: copy, then clear the session cookies) (SameOriginProxyTransformer.cs:17,20,23). - Depends on: nothing first-party.
- Concept: a strategy selector passed into the per-request
SameOriginProxyTransformer; see that section for each mode's mechanism. - Walkthrough:
SameOriginApiProxyEndpointpicks the mode inResolveMode(SameOriginApiProxyEndpoint.cs:291-311): every non-POST isForward; a POST to the refresh path yieldsnull(the proxy answers it itself); a POST to a token-issuing path isTokenIssuing; a POST to the revoke path isRevoke; any other POST isForward. - Why it's built this way: the endpoint decides once per request and the transformer branches on it (
SameOriginProxyTransformer.cs:64,84,91), so the request path matching stays in one class and the body rewriting in another. - Where it's used:
SameOriginApiProxyEndpointandSameOriginProxyTransformeronly; it isinternal.
SameOriginApiProxyMarker
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web.SameOriginProxy·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SameOriginApiProxyServiceExtensions.cs:92· Level 0 · class
- What it is: an empty
internal sealed classregistered as a singleton only byAddCommonSameOriginApiProxy(SameOriginApiProxyServiceExtensions.cs:81,92), so other code can ask the container "did this host opt in to the proxy?". - Depends on: nothing.
- Concept: a DI marker service: presence in the container is the signal, the instance carries no state.
- Walkthrough: no members.
- Why it's built this way: checking a private marker type is cheaper and more precise than probing for a public service a host could register for other reasons (for example the options type, which binding alone would create).
- Where it's used:
SameOriginApiProxyEndpointExtensionsthrows when it is absent, naming the missing call (SameOriginApiProxyEndpointExtensions.cs:38-42);ClientConfigEndpointExtensionsreads it to decide whether to publish the proxy's path prefix to the client (MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/ClientConfig/ClientConfigEndpointExtensions.cs:93,98).
SameOriginApiProxySettings
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web.SameOriginProxy·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SameOriginApiProxySettings.cs:21· Level 0 · class
- What it is: the options class for the same-origin API proxy, bound from configuration section
SameOriginApiProxy(SameOriginApiProxySettings.cs:24) and validated at startup. - Depends on:
SameSiteMode(ASP.NET Core); first-party only by reference: the gateway address falls back toApiSettings'sApiEndpoint. - Concept: options pattern with fail-fast validation, same shape as the other settings classes in this group (for example
BlazorCspSettingswithBlazorCspSettingsValidator).[Rubric §33, Developer Experience]assesses whether misconfiguration surfaces early and clearly; every knob here has a default, so a host opts in with no configuration at all. - Walkthrough:
DefaultTokenIssuingPaths=auth/login,auth/register,auth/oauth/exchange(SameOriginApiProxySettings.cs:31): the endpoints whose successful response carries a token pair.PathPrefix=/api(:38): a request to{PathPrefix}/orders/5is forwarded to{gateway}/orders/5.GatewayAddress(:46): null by default;AddCommonSameOriginApiProxyfills it fromApi:ApiEndpointwhen unset (SameOriginApiProxyServiceExtensions.cs:57-64), so an Aspire discovery name such ashttps+http://gatewayresolves as it does for the host's API clients.AdditionalTokenIssuingPaths(:52): empty; a host adds paths such asauth/2fa/verify.RefreshPath=auth/refresh(:59): answered by the proxy itself from the refresh cookie.RevokePath=auth/revoke(:66): forwarded, then the cookies are cleared whatever upstream answered.SessionCookieSameSite=SameSiteMode.Strict(:74);Laxis the only other accepted value.
- Why it's built this way: recorded in ADR-131: a WebAssembly client calling the gateway cross-origin cannot carry the HttpOnly session cookie, so tokens end up script-readable; a same-origin proxy on the UI host lets the cookie authenticate data calls instead.
- Where it's used:
SameOriginApiProxyServiceExtensions(binding, and copyingSessionCookieSameSiteontoSessionCookieSettings,SameOriginApiProxyServiceExtensions.cs:69-74),SameOriginApiProxySettingsValidator,SameOriginApiProxyEndpoint,SameOriginApiProxyEndpointExtensionsandClientConfigEndpointExtensions.cs:98.
SameOriginProxyInvoker
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web.SameOriginProxy·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SameOriginProxyInvoker.cs:19· Level 0 · class
- What it is: an
IDisposableholder for the oneHttpMessageInvokerYARP's forwarder sends every proxied request through (SameOriginProxyInvoker.cs:19,37). - Depends on:
IServiceDiscoveryHttpMessageHandlerFactory(Microsoft.Extensions.ServiceDiscovery),SocketsHttpHandler, YARP'sReverseProxyPropagator. - Concept: YARP's
IHttpForwarderexpects a long-lived invoker with a transport tuned for proxying, not a pooledHttpClient.[Rubric §12, Performance & Scalability]assesses connection reuse and resource lifetime: one singleton transport is shared by all requests. - Walkthrough:
- The public ctor resolves the optional discovery factory and builds the handler (
SameOriginProxyInvoker.cs:24-31); the invoker is created withdisposeHandler: falseand the class keeps_ownedHandlerto dispose itself (:22,39-43). - An
internalctor takes a test-supplied handler, for example a TestServer's (:34-35); that handler is not owned or disposed. CreateHandler(:45-69) configuresSocketsHttpHandler:UseProxy = false,AllowAutoRedirect = false, no automatic decompression,UseCookies = false, multiple HTTP/2 connections,ConnectTimeout15 s, and aReverseProxyPropagatoroverDistributedContextPropagator.Current(:50-59). When discovery is registered it wraps the transport (:61) sohttps+http://gatewayresolves. The null-out-then-finallypattern disposes the transport only if wrapping throws.
- The public ctor resolves the optional discovery factory and builds the handler (
- Why it's built this way: redirects, cookies and decompression must pass through to the browser untouched, which is why they are all off; the propagator keeps the trace context flowing to the gateway.
- Where it's used: injected into
SameOriginApiProxyEndpoint(SameOriginApiProxyEndpoint.cs:31,342), registered bySameOriginApiProxyServiceExtensions.cs:78, and built from a handler in the test harnessMMCA.Common/Tests/Presentation/MMCA.Common.UI.Web.Tests/SameOriginProxy/ProxyTestHarness.cs.
TokenPair
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web.SameOriginProxy·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SessionHandoffProtector.cs:75· Level 0 · record
- What it is: a private nested record
(AccessToken, RefreshToken)ofSessionHandoffProtector(SessionHandoffProtector.cs:75), the plaintext shape serialized to JSON before it is sealed. - Depends on: nothing first-party;
System.Text.Json. - Walkthrough: serialized in
ProtectTokenPair(SessionHandoffProtector.cs:33-34) and deserialized inUnprotectTokenPair, which rejects a null pair or either token blank (:46-49). - Where it's used: only inside
SessionHandoffProtector.cs; callers see a value tuple instead.
SameOriginApiProxySettingsValidator
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web.SameOriginProxy·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SameOriginApiProxySettingsValidator.cs:14· Level 1 · class
- What it is: the
IValidateOptions<SameOriginApiProxySettings>that fails the boot on an unusable proxy configuration, collecting every failure into one result (SameOriginApiProxySettingsValidator.cs:14,19-51). - Depends on:
SameOriginApiProxySettings;SearchValues<char>,ValidateOptionsResult. - Concept: startup validation via
ValidateOnStart, cross-referenced toBlazorCspSettingsValidator.[Rubric §11, Security]applies to theSameSitecheck: onlyStrictorLaxpass, because the cookie now authenticates data calls (:40-43). - Walkthrough:
ForbiddenPathCharacters={}*?#\(:16): route syntax, query, fragment and backslash.PathPrefixmust passIsValidPrefix(:26-31,54-60): non-blank, longer than one char, starts with/, does not end with/, no whitespace, no forbidden char. It becomes a route pattern, hence the literal-path rule.GatewayAddressmust be an absolute URI with a host (:33-38); the message names theApi:ApiEndpointfallback.RefreshPath,RevokePathand everyAdditionalTokenIssuingPathsentry must passIsValidRelativePath(:45-49,62-67): not blank after trimming/, no whitespace, no forbidden char, no://.
- Why it's built this way: an invalid prefix would otherwise surface as a route-pattern exception or a silently unmatched route at request time; naming the setting at boot is cheaper to diagnose.
- Where it's used: registered with
TryAddEnumerablebySameOriginApiProxyServiceExtensions.cs:66-67.
SessionHandoffProtector
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web.SameOriginProxy·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SessionHandoffProtector.cs:16· Level 1 · class
- What it is: seals tokens with ASP.NET Core Data Protection so the Blazor Server circuit can pass them through browser script without script being able to read or replay them later (
SessionHandoffProtector.cs:16). - Depends on:
IDataProtectionProvider/ITimeLimitedDataProtector(Microsoft.AspNetCore.DataProtection); nestedTokenPair. - Concept introduced, the protected handoff. With the proxy enabled the HttpOnly cookies are the only session store, but a Server circuit runs over a WebSocket where it cannot read cookies on demand, and it may need to seed the cookies after a sign-in. The handoff hops through JS interop carrying an opaque blob that only this server can open, valid for one minute.
[Rubric §26, Front-End Security]assesses keeping credentials out of script-reachable storage: the browser holds ciphertext with a 1-minute lifetime, never a usable bearer. - Walkthrough:
Lifetime= 1 minute, "one fetch plus one interop hop" (:19).- Two purpose-isolated protectors under the root
MMCA.Common.UI.Web.SameOriginProxy.SessionHandoff, one forAccessToken, one forTokenPair(:21-27), so a blob minted for one cannot be opened as the other. ProtectAccessToken/UnprotectAccessToken(:29,31);ProtectTokenPairserializes aTokenPairto JSON (:33-34);UnprotectTokenPairreturns a tuple or null on a missing, blank or malformed pair (:36-55).TryUnprotect(:57-73) maps null or whitespace input, andCryptographicExceptionorFormatException(tampered, expired, wrong purpose, retired key), to null.
- Why it's built this way: see ADR-131. Returning null instead of throwing lets each endpoint answer a clean 401 or 400.
- Where it's used:
SessionHandoffEndpoints,HandoffTokenRefresher,HandoffSessionCookieSync; registered as a singleton atSameOriginApiProxyServiceExtensions.cs:80; tested inMMCA.Common/Tests/Presentation/MMCA.Common.UI.Web.Tests/SameOriginProxy/SameOriginApiProxyAuthFlowTests.csandSessionHandoffServicesTests.cs. - Caveats / not-in-source: whether the host's Data Protection key ring is persisted and shared across replicas is host configuration;
AddDataProtection()here (SameOriginApiProxyServiceExtensions.cs:77) adds no persistence. Not determinable from this source.
HandoffSessionCookieSync
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web.SameOriginProxy·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/HandoffSessionServices.cs:52· Level 2 · class
- What it is: the proxy-mode
ISessionCookieSyncfor the Blazor Server circuit: after a sign-in it hands the browser a protected token pair to post back, instead of the raw tokens (HandoffSessionServices.cs:52). - Depends on:
IJSRuntime,SessionHandoffProtector. - Concept: cross-reference
SessionHandoffProtector; it replaces the default sync (compareJsFetchSessionCookieSync). - Walkthrough:
SyncAsynccallsmmcaAuthHandoff.setCookiewithProtectTokenPair(...)(:41-51); the script posts it to/auth/session-cookie/handoff(seeSessionHandoffEndpoints).ClearAsynccallsmmcaAuthCookie.clear(:53-63). Both returnfalsewhen interop is unavailable (InvalidOperationException,JSDisconnectedException,JSException,OperationCanceledException,:65-66). - Where it's used: registered with
Replaceas scoped (SameOriginApiProxyServiceExtensions.cs:84) and verified at map time bySameOriginApiProxyEndpointExtensions. - Caveats / not-in-source: the
mmcaAuthHandoff/mmcaAuthCookieJavaScript is not in this C# source and was not read.
HandoffTokenRefresher
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web.SameOriginProxy·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/HandoffSessionServices.cs:14· Level 2 · class
- What it is: the proxy-mode
ISessionAwareTokenRefresher(anITokenRefresher) for the Blazor Server circuit: it gets an access token from the cookie session through a protected handoff (HandoffSessionServices.cs:14). - Depends on:
IJSRuntime,SessionHandoffProtector. - Concept: cross-reference
SessionHandoffProtector; it replaces the default refresher (compareDirectApiTokenRefresher). - Walkthrough:
AcquireAccessTokenAsyncis a thin wrapper that returns theAccessTokenofTryAcquireAccessTokenAsync(:16-17). The latter callsmmcaAuthHandoff.getToken(:24), whose script calls/auth/session/handoffand answers null only for the endpoint's 401. A null handoff yieldsTokenAcquisition.NoSession(:25-28). Otherwise the blob is unprotected server-side (:32): a non-empty token yieldsAcquired, while an empty result (expired handoff, or a key this host does not hold) yieldsUnavailablebecause it says nothing about the session itself (:32-34). Interop failure during SSR prerender, a disconnected circuit, a cancelled call or a transient endpoint failure also yieldsUnavailable(:36-42). - Where it's used: registered with
Replaceas scoped (SameOriginApiProxyServiceExtensions.cs:83); tested inSessionHandoffServicesTests.cs. - Caveats / not-in-source: the JavaScript side is not in this C# source.
SameOriginProxyTransformer
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web.SameOriginProxy·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SameOriginProxyTransformer.cs:34· Level 12 · class
- What it is: the per-request YARP
HttpTransformerthat rewrites the outbound request (path, bearer, cookies) and treats the response according to itsProxyResponseMode(SameOriginProxyTransformer.cs:34-39). - Depends on:
ISessionCookieStore,SessionCookieEndpoints(cookie names),SessionClaimsToken,SameOriginProxyHeaders; YARPHttpTransformerandRequestUtilities. - Concept:
[Rubric §26, Front-End Security](credentials kept out of script): the token pair from a sign-in never reaches the browser body, and the cookie jar's token copies never reach the upstream. - Walkthrough:
TransformRequestAsync(:47-69): runs YARP's default, strips the prefix to build the destination URI (:55-58), setsAuthorization: Bearerfrom the session or removes it (:60), drops the CSRF header (:61), removes the session cookies (:62), and inTokenIssuingmode clearsAccept-Encodingso the body arrives as plain JSON (:64-68).TransformResponseAsync(:71-98): whencaptureUnauthorizedis on and upstream answered 401, setsUnauthorizedCapturedand copies nothing so the endpoint can replay (:76-80); inRevokeclears the cookies whatever upstream said (:84-89); inTokenIssuingon success rewrites the body (:91-95).RewriteTokenResponseAsync/TryMoveTokensToCookies(:106-145): parses the JSON case-insensitively, writes both tokens to the cookie store (:141), replacesaccessTokenwith its claims-only form and emptiesrefreshToken(:142-143), and setsno-store(:115-117). A body without a token pair passes through unchanged.RemoveSessionCookies(:156-174): filters only the access and refresh cookie pairs from theCookieheader; other cookies pass.
- Why it's built this way: the browser-facing JSON keeps its shape, so a client written for the non-proxy mode still parses a well-formed response (
:100-105). - Where it's used: created per request by
SameOriginApiProxyEndpoint(SameOriginApiProxyEndpoint.cs:336-337).
SameOriginApiProxyEndpoint
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web.SameOriginProxy·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SameOriginApiProxyEndpoint.cs:29· Level 13 · class
- What it is: the request handler behind
{PathPrefix}/{**path}: gates the request, resolves the session from the cookies, forwards to the gateway with a bearer, and refreshes and replays once on an unexpected 401 (SameOriginApiProxyEndpoint.cs:29-35,60-105). - Depends on: YARP
IHttpForwarder,SameOriginProxyInvoker,ICookieSessionRefresherwithSessionRefreshOutcome/SessionRefreshStatus,ISessionCookieStore,SameOriginApiProxySettings,SameOriginProxyTransformer,SessionClaimsToken. - Concept introduced, a Backend-for-Frontend proxy. The UI host forwards same-origin calls to the gateway, so the browser's cookie authenticates them and the bearer is attached server-side.
[Rubric §26, Front-End Security]assesses CSRF and origin defenses: a same-origin gate and a CSRF header gate run before anything is forwarded.[Rubric §29, Resilience & Business Continuity]assesses graceful degradation: an undecided refresh answers 503 withRetry-Afterand keeps the session. - Walkthrough:
- Config:
RequestConfiguses a 100 s activity timeout and HTTP/1.1 withRequestVersionOrLower(:43-48), which lets YARP turn an HTTP/2 WebSocket into an HTTP/1.1 upgrade;DefaultRetryAfter5 s (:51);_tokenIssuingPathsmerges the defaults and additions case-insensitively (:54-58). InvokeAsync(:60-105): disables status code pages so a proxied empty 401/404 is not replaced by an HTML page (:66-69); runsTryAnswerBeforeForwardingAsync; resolves the mode, withnullmeaningRefreshLocallyAsync(:76-81); for non-sign-in requests that carry a session cookie callsValidateOrRefreshAsyncand fails viaFailRefreshAsyncwhen no session results (:85-96); forwards with 401 capture on only for a bearer plus a replayable request (:98-99); replays on capture (:101-104).- Gates, in order (
TryAnswerBeforeForwardingAsync,:232-259):CrossOriginRejection(403cross_origin_rejected);OPTIONSanswered 204 locally, never forwarded, no CORS grant; unsafe methods other than an HTTP/2 WebSocket CONNECT need the CSRF header (403csrf_header_required).HasCsrfHeaderrequires exactly one header value equal toSameOriginProxyHeaders's value (:110-113). CrossOriginRejection(:131-137):OriginRejectionrefuses a foreignOriginand an upgrade with noOrigin(:180-188);IsOwnOrigincompares scheme and server against the request as seen after forwarded headers, refusing paths, user info and thenullorigin (:146-160);FetchSiteRejectionallowssame-origin, andnoneonly on a non-upgrade GET or HEAD (:190-207).IsWebSocketExtendedConnectdetects RFC 8441 CONNECT (:169-172).IsReplayable(:215-217): GET, HEAD or OPTIONS and not an upgrade, so nothing with a body is re-sent.FailRefreshAsync(:274-285):Rejectedends the session (clear cookies, 401session_expired,:262-266); anything else answers 503session_refresh_unavailablewithRetry-Afterfrom upstream or 5 s.RefreshAndReplayAsync(:318-329): resets status to 200, forcesRefreshAsync, forwards again without capture.RefreshLocallyAsync(:356-374): rotates from the refresh cookie and answers the sign-in shape with a claims-only access token, empty refresh token and the expiry,no-store.- Four
[LoggerMessage]warnings (:376-386);ForwardAsynclogs a forward error only if the client did not abort (:339-349).
- Config:
- Why it's built this way: ADR-131 records the decision; the gateway stays the API edge and authorizes the forwarded bearer.
- Where it's used: mapped by
SameOriginApiProxyEndpointExtensions; itsHasCsrfHeaderis reused bySessionHandoffEndpoints(SessionHandoffEndpoints.cs:26,44); registered as a singleton atSameOriginApiProxyServiceExtensions.cs:79.
SameOriginApiProxyServiceExtensions
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web.SameOriginProxy·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SameOriginApiProxyServiceExtensions.cs:37· Level 14 · class
- What it is: the opt-in registration,
services.AddCommonSameOriginApiProxy(configuration)(SameOriginApiProxyServiceExtensions.cs:51), written as a C#extension(IServiceCollection)block (see C#extension(T)types). - Depends on: every proxy type above;
ApiSettings,SessionCookieSettings,ITokenRefresher,ISessionCookieSync; YARPAddHttpForwarder, Data Protection. - Walkthrough:
- Binds
SameOriginApiProxySettings, post-configures the gateway fromApi:ApiEndpointwhen unset,ValidateOnStart(:55-65), and adds the validator (:66-67). - Configures
SessionCookieSettings:SameSitefrom the proxy settings andClaimsOnlyBrowserTokens = true(:69-74). AddHttpForwarder,AddDataProtection(:76-77); singletons for the invoker, endpoint, protector andSameOriginApiProxyMarker(:78-81).Replaces the scopedITokenRefresherandISessionCookieSyncwithHandoffTokenRefresherandHandoffSessionCookieSync(:83-84).
- Binds
- Why it's built this way: opt-in per host; a host that never calls it is unchanged (ADR-131).
Replacemeans call order matters, whichMapCommonSameOriginApiProxychecks. - Where it's used: paired with
SameOriginApiProxyEndpointExtensions; exercised inMMCA.Common/Tests/Presentation/MMCA.Common.UI.Web.Tests/SameOriginProxy/SameOriginApiProxyOptInTests.cs. - Caveats / not-in-source: which consumer hosts call it is not determinable from this source (no call site under
MMCA.Common/Sourceother than its own pairing).
SessionHandoffEndpoints
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web.SameOriginProxy·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SessionHandoffEndpoints.cs:15· Level 14 · class
- What it is: maps the two Blazor Server circuit handoff endpoints,
POST /auth/session/handoffandPOST /auth/session-cookie/handoff(SessionHandoffEndpoints.cs:17-18,20). - Depends on:
ICookieSessionRefresher,ISessionCookieStore,SessionHandoffProtector,SameOriginApiProxyEndpoint(HasCsrfHeader),HandoffBody. - Walkthrough:
- Token handoff (
:23-38): requires the CSRF header (else 403), callsGetOrRefreshAsyncon the cookie session, answers 401no_sessionor aHandoffBodywith a protected access token. - Cookie handoff (
:41-60): requires the CSRF header, unprotects the token pair (400invalid_handoffon failure), writes the cookies, answers 204. - Both are
ExcludeFromDescription,AllowAnonymous,DisableAntiforgery; the CSRF header is the gate.
- Token handoff (
- Where it's used: called from
MapCommonSameOriginApiProxy(SameOriginApiProxyEndpointExtensions.cs:54); reached from the browser byHandoffTokenRefresherandHandoffSessionCookieSyncthrough their JS interop.
SameOriginApiProxyEndpointExtensions
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web.SameOriginProxy·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SameOriginApiProxyEndpointExtensions.cs:15· Level 15 · class
- What it is:
endpoints.MapCommonSameOriginApiProxy()(SameOriginApiProxyEndpointExtensions.cs:33), which maps{PathPrefix}/{**path}for every method (WebSocket upgrades included) plus the handoff endpoints, after checking the registrations. - Depends on:
SameOriginApiProxyMarker,SameOriginApiProxySettings,SameOriginApiProxyEndpoint,SessionHandoffEndpoints,HandoffTokenRefresher,HandoffSessionCookieSync. - Concept: fail-fast wiring checks.
[Rubric §33, Developer Experience]: a wrong registration order fails the boot with a message naming the fix rather than silently leaking tokens. - Walkthrough:
- Throws
InvalidOperationExceptionif the marker is absent (:38-42). VerifyCircuitRegistrations(:64-69) resolvesITokenRefresherandISessionCookieSyncin a scope; a resolved type other than the handoff implementation throws, telling the host to callAddCommonSameOriginApiProxyafter every such registration (:71-92). A missing dependency such asIJSRuntime(no interactive Server circuit) skips the check (:79-83).- Maps the proxy route to
proxy.InvokeAsyncasExcludeFromDescription,AllowAnonymous,DisableAntiforgery(:49-52), thenSessionHandoffEndpoints.Map(:54).
- Throws
- Why it's built this way: a later
ITokenRefresherregistration would put the access token back in the page on every Server-circuit hydration (:59-63); the doc comment asks hosts to map it afterUseAuthorization(:19-26). - Where it's used: by consumer hosts that opted in. Not determinable from this source which hosts do.
TrustedCallerHandler
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web.Security·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Security/TrustedCallerHandler.cs:35· Level 0 · class (sealed,DelegatingHandler)
- What it is: an outgoing
HttpClienthandler that stamps a shared-secret header onto a request only when the request targets the configured gateway origin, so the gateway can trust that the call came from this app rather than from an arbitrary caller. - Depends on: nothing first-party. Externals:
System.Net.Http(DelegatingHandler,HttpRequestMessage,HttpResponseMessage), BCLUri. - Concept introduced, an edge-trust header scoped to origin match, not applied blindly.
[Rubric §11, Security]assesses whether a trust signal is bounded to the audience it is meant for. The constructor takes a header name, a shared secret, and the gateway origin (TrustedCallerHandler.cs:39), andSendAsynconly stamps the header when the outgoing request's scheme, host, and port match that origin (TrustedCallerHandler.cs:57-63); a request to any other destination goes out unmodified, so the secret cannot leak to a third-party origin the sameHttpClienthappens to call. ADR-088 is the policy this enforces on the client side. - Walkthrough
- The constructor (
TrustedCallerHandler.cs:39-48) null/blank-guardsheaderNameandsecretand null-guardsgatewayOrigin, storing all three as readonly fields. SendAsync(TrustedCallerHandler.cs:51-72) null-guards the request, then comparesrequest.RequestUriagainst_gatewayOriginwithUri.CompareoverUriComponents.Scheme | UriComponents.HostAndPort, case-insensitive (TrustedCallerHandler.cs:57-63). On a match it removes any existing value for the header and adds exactly one viaTryAddWithoutValidation(TrustedCallerHandler.cs:67-68); the comment (TrustedCallerHandler.cs:65-66) explains the replace-not-append choice, the gateway compares one header value in constant time, so a second value would silently fail the comparison. It then callsbase.SendAsyncunconditionally.
- The constructor (
- Why it's built this way: a
DelegatingHandlercomposes into the existingHttpClientpipeline without every call site having to remember to add the header itself, and gating on origin means the same registered handler is safe to reuse on a client that also talks to other hosts. - Where it's used: registered in
MMCA.Common.UI.Web's DependencyInjection (MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs); its behavior is pinned byTrustedCallerHandlerTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Web.Tests/Security/TrustedCallerHandlerTests.cs).
BlazorCspSettings
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web.Security·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Security/BlazorCspSettings.cs:18· Level 0 · class (sealed)
- What it is: the bindable options type for an opt-in
frame-srcallowance on the Blazor host's Content-Security-Policy, one string list of origins a page may embed in an<iframe>(BlazorCspSettings.cs:23-35). - Depends on: nothing first-party. It is read by BlazorCspPolicyProvider through
BuildFrameSrcand validated at startup by BlazorCspSettingsValidator. Externals:Microsoft.Extensions.Optionsbinding conventions (BCLIList<string>). - Concept introduced, an empty default that changes nothing.
[Rubric §26, Front-End Security]assesses whether a security-relevant default stays strict until a deployment explicitly opts out.SectionName = "BlazorCsp"(line 28) names the configuration section;FrameSources(line 42) initializes to an empty list, so an unconfigured host emits noframe-srcdirective at all and the policy stays byte-identical to the pre-existing baseline (comment, lines 30-33). Only a deployment that needs to embed a specific third-party origin (the doc comment's example is a map provider's embed endpoint, lines 30-31) adds entries.- The doc comment (lines 33-40) states the origin shape a configured entry must satisfy: an absolute
httpsorigin with no path, query, fragment, user info, wildcard, quote, semicolon, comma or whitespace, so the field carries the contract BlazorCspSettingsValidator enforces rather than leaving it implicit.
- The doc comment (lines 33-40) states the origin shape a configured entry must satisfy: an absolute
- Walkthrough: two members:
SectionName(line 28, apublic const string) andFrameSources(line 42,public IList<string>initialized to[]). - Why it's built this way: a settings class bound through
IOptions<BlazorCspSettings>fits the same registration and validation pipeline every other options type in the host uses, so addingframe-srcsupport did not need a new configuration mechanism, only a new section. - Where it's used: bound and validated in
MMCA.Common.UI.Web's DependencyInjection (MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs), consumed by BlazorCspPolicyProvider's constructor asIOptions<BlazorCspSettings>.
BrowserOriginHandler
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web.Services·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Services/BrowserOriginHandler.cs:32· Level 0 · class (internal sealed,DelegatingHandler)
- What it is: an outgoing
HttpClienthandler that stamps the visitor's browser origin (X-Forwarded-Forwith its address,User-Agentwith its user-agent) on the server-side"APIClient"calls a Blazor Server host makes for that visitor (BrowserOriginHandler.cs:5-11). Without it the API would see this host's own address for every visitor, and the host's HTTP client sends no user-agent at all. - Depends on: nothing first-party. Externals:
Microsoft.AspNetCore.Http(IHttpContextAccessor,ConnectionInfo.RemoteIpAddress),System.Net.Http(DelegatingHandler,HttpRequestMessage). - Concept introduced, per-client policy keyed on the visitor rather than the host.
[Rubric §11, Security]assesses whether a trust or rate-limit signal is attributed to the right principal. The API keys the registration rate limit, the session's recorded IP and the recorded device on the caller; behind a Blazor Server host every caller would collapse onto the host's address. The handler copies two values from the HTTP request that carries the work (the page request during prerender, the connection the circuit was established on afterwards,BrowserOriginHandler.cs:14-17). The address isRemoteIpAddress, which has already passed through this host's forwarded-headers middleware, so it is only as far back as the host trusts its own proxies, and a client-suppliedX-Forwarded-Foris never copied verbatim (BrowserOriginHandler.cs:16-19). - Walkthrough
- The primary constructor (line 32) takes
IHttpContextAccessor, an async-local that crosses the handler's own DI scope (parameter doc, lines 30-31). Two internal constants name the headers:ForwardedForHeaderName(line 35) andUserAgentHeaderName(line 38). SendAsync(lines 41-55) null-guards the request, readshttpContextAccessor.HttpContext(line 47) and, only when a request is in scope, callsReplacefor the connection's remote address (line 50) and the request'sUser-Agent(line 51), then callsbase.SendAsyncunconditionally (line 54). With no visitor behind the call (a background job) nothing is sent.Replace(lines 57-68) returns early on a null or blank value (lines 59-62), so a blank user-agent is not forwarded; otherwise it removes any existing header (line 66) and adds exactly one value viaTryAddWithoutValidation(line 67). The comment (lines 64-65) gives the reason for skipping validation: a real browser user-agent does not always parse as a strict product-token list, and neither value needs parsing on this side.
- The primary constructor (line 32) takes
- Why it's built this way: a
DelegatingHandlercomposes into the named client's pipeline without any call site remembering to add the headers, and replace-not-append keeps each header single-valued so a caller cannot smuggle a second value in. It is the same shape as TrustedCallerHandler, but unconditional on origin because the target is always this app's own API. - Where it's used: registered transient and attached to the
"APIClient"named client byAddCommonServerTokenStorage()in theMMCA.Common.UI.WebDependencyInjection (MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs:47-48); only a Blazor Server host calls that, because the WebAssembly client reaches the API through the same-origin proxy, which forwards the browser's own headers (BrowserOriginHandler.cs:23-28). Pinned byBrowserOriginHandlerTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Web.Tests/Services/BrowserOriginHandlerTests.cs).
NotificationSendModel
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Notifications/NotificationSendModel.cs:19· Level 1 · class (sealed)
- What it is: the two-property form model for the push-notification compose page. Its DataAnnotations are the single declaration of that form's field rules.
- Depends on: first-party: SendPushNotificationRequest (the endpoint contract, whose length constants it reuses, lines 23 and 28). Externals:
System.ComponentModel.DataAnnotations(RequiredAttribute,MaxLengthAttribute). It is consumed by NotificationSend through ModelValidation and DataAnnotationsModelValidator. - Concept introduced, one number shared by the cap, the message, and the server invariant. The lengths are not declared here.
MaxLength(SendPushNotificationRequest.TitleMaxLength)(line 23) andMaxLength(SendPushNotificationRequest.BodyMaxLength)(line 28) point at the shared request contract, which fixes them at 200 and 2000 (MMCA.Common/Source/Core/MMCA.Common.Shared/Notifications/PushNotifications/SendPushNotificationRequest.cs:15,21). The same constants drive the input cap and the character counter in the markup (NotificationSend.razor:49-50,61-62), and the server-side validator enforces the same numbers.[Rubric §24, Forms, Validation & UX Safety]assesses whether client and server agree. Here they cannot disagree, because there is one literal and everything else is a reference to it.[Rubric §9, API & Contract Design]assesses whether contract facts live with the contract; putting the length constants on the request record (the type the endpoint binds) rather than on the form model is what makes the sharing possible in the first place.[Rubric §27, Internationalization & Localization]: eachErrorMessageis a resource key ("Notif.Send.Field.Title.Required", line 22, and its three siblings), resolved by the page's localizing DataAnnotationsModelValidator per ADR-027. This is the concrete case the pass-through localization in that validator was designed for.
- Walkthrough: two mutable
stringproperties, both initialized tostring.Emptyso a fresh model binds cleanly.Title(line 24) carries[Required]with keyNotif.Send.Field.Title.Required(line 22) and[MaxLength(200)]with keyNotif.Send.Field.Title.MaxLength(line 23).Body(line 29) carries the matching pair,Notif.Send.Field.Message.Required(line 27) andNotif.Send.Field.Message.MaxLengthwith the 2000-character cap (line 28). - Why it's built this way: a settable class rather than a record with
initaccessors, because MudBlazor two-way binding (@bind-Value="_model.Title") writes back into the instance. It is a separate type from SendPushNotificationRequest because the form model is mutable and carries presentation rules, while the request record is the immutable wire contract; the page maps one to the other in a single line (NotificationSend.razor.cs:131). - Where it's used: held as
private readonly NotificationSendModel _model = new()by NotificationSend (NotificationSend.razor.cs:46) and bound by both fields in its markup.
BiometricGate
MMCA.Common.UI ·
MMCA.Common.UI.Components.Capabilities·MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/Capabilities/BiometricGate.razor.cs:18· Level 1 · class (partial, component code-behind)
- What it is: a hybrid-head app-lock gate. It locks the UI behind a biometric prompt whenever the
app returns from the background having sat there longer than
ReLockAfter, so a device handed to someone else, or simply left unattended, does not reopen straight into a signed-in session. - Depends on: injected services resolved by the component (
AppLifecycle'sResumedevent,Preferences,TokenStorage,AuthService(IAuthUIService, used only to sign out),Biometrics,Navigation,Logger, the page's localizerL); MudBlazor'sMudButtonfor the focus target (_unlockButton, line 30). - Concept introduced, an async-void lifecycle handler that cannot afford to throw.
[Rubric §11, Security]assesses whether a re-auth control actually re-arms on every qualifying event, not just the first one. The class subscribes toAppLifecycle.Resumedon first render regardless of the current preference, because app lock can be switched on later in the same session (comment, lines 53-54), and every resume re-reads the preference.[Rubric §18, UI Architecture & Component Design]covers the handler shape itself:OnResumed(line 94) is a plainEventHandler<AppResumedEventArgs>, so it cannot beasync; it firesReLockAsyncwith an explicit discard, and the security comment (lines 88-93) states why that discard is safe here,ReLockAsyncobserves its own failures with a catch-all, and an unobserved exception in a true async-void handler would crash a native head's process (VSTHRD100). - Concept introduced, a precondition read that fails open before opt-in and closed after it.
[Rubric §11, Security]also assesses which way a control fails when its own inputs are unreadable. The class summary states the rule (lines 15-16) andShouldLockAsyncdocuments both halves (lines 133-138): if theAppLockEnabledpreference itself cannot be read, nothing says the owner opted in, so the gate stays open rather than forcing a biometric prompt on a device that may have none enrolled; if the preference reads as on but the stored session cannot be read, the session state is unknown and the gate locks. Either way the method never throws, which matters because it runs from both the first render and the resume path. - Walkthrough
ReLockAfter(lines 25-26) is a[Parameter]TimeSpandefaulting to 30 seconds; the comment (lines 20-24) explains the default is deliberately short, a device handed off is usually away from its owner longer than a glance at a notification.OnAfterRenderAsync(firstRender)(lines 40-66): first focuses the Unlock button if_focusUnlockPendingand the button reference now exists (lines 42-46), because that flag can only be acted on once the locked branch has actually rendered (field comment, lines 32-36). OnfirstRenderit subscribes toAppLifecycle.Resumed(lines 55-56), then callsShouldLockAsyncand, if it returns true, sets_lockedand awaitsUnlockAsync(lines 58-65).ReLockAsync(lines 96-131) returns early if already locked or if the background duration was underReLockAfter(line 100), otherwise dispatches the lock-and-unlock sequence throughInvokeAsync(lines 105-115). It catchesObjectDisposedExceptionandInvalidOperationExceptionas expected teardown races (lines 117-124), and a generalExceptionlast, logged rather than rethrown, with the comment (lines 127-128) recording that this is the exhaustive catch an async-void handler needs.ShouldLockAsync(lines 139-167) wraps the preference read in its owntry(lines 142-150): a failure is logged as a warning and returns false. A preference that is off also returns false (lines 152-155). Otherwise a secondtry(lines 157-166) returns whether a refresh token is still stored, nothing to protect without a session, and a failure there is logged and returns true (line 165).UnlockAsync(lines 169-183) callsBiometrics.AuthenticateAsyncwith a localized prompt reason; success clears_locked, failure queues_focusUnlockPendingfor the next render rather than focusing immediately, because the button does not exist yet on the very first lock.FocusUnlockAsync(lines 189-203) toleratesJSDisconnectedException(circuit gone) andInvalidOperationException(prerendering, or the element already removed).SignOutAsync(lines 209-214) callsAuthService.LogoutAsync()(line 211), clears_locked, and navigates to/login. The doc (lines 205-208) states why it is the app's own logout and never a bare token clear: clearing the tokens alone left Blazor's auth state signed in and the previous session's read cache and offline snapshots on the device.Dispose(bool)(lines 70-79) unsubscribes fromAppLifecycle.Resumedif subscribed, guarded so it is safe to call more than once; the publicDispose()(lines 82-86) forwards to it and suppresses finalization.
- Why it's built this way: the re-lock decision is driven entirely by wall-clock background
duration rather than any UI state, so it behaves the same whether the app was backgrounded for a
phone call or for the rest of the day. Catching broadly inside
ReLockAsyncbut narrowly insideFocusUnlockAsyncreflects the difference between an operation whose caller cannot observe a failure at all (the lifecycle event) and one whose failure just means "skip the focus this time". The two catches insideShouldLockAsyncresolve in opposite directions on purpose: before opt-in the safe default is "no lock", after opt-in it is "lock". - Where it's used: MMCA.ADC's
DeviceUIModule(MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/DeviceUIModule.cs) andApp.xaml.cs(MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/App.xaml.cs); pinned byBiometricGateTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Components/Capabilities/BiometricGateTests.cs).
BlazorCspSettingsValidator
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web.Security·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Security/BlazorCspSettingsValidator.cs:13· Level 1 · class (internal, sealed)
- What it is: the startup
IValidateOptions<BlazorCspSettings>that rejects aframe-srcorigin the moment it is misshapen, so a bad entry fails fast rather than silently reaching the CSP header. - Depends on: first-party: BlazorCspSettings (the options type it validates). Externals:
Microsoft.Extensions.Options(IValidateOptions<T>,ValidateOptionsResult), BCLSearchValues<char>,Uri. - Concept introduced, an explicit character denylist plus a shape check, not a regex. The fail-fast configuration contract of ADR-070 (Website/docs-src/adr/070-fail-fast-configuration-contract.md) assesses whether a misconfigured setting is caught at startup instead of at request time.
Validate(lines 21-31) runs every configuredFrameSourcesentry throughIsValidOriginand fails the whole registration with one message per bad entry (lines 26-30) rather than starting the host on a policy it cannot honor.ForbiddenCharacters(line 18, aSearchValues<char>over*'";,@?#) is the set that could either break out of a CSP source expression or widen it, plus the URL delimiters (user info, query, fragment) a plain origin never carries.IsValidOrigin(lines 21-25 of the walkthrough below) rejects on that set before it ever callsUri.TryCreate, so a value crafted to look like a URL but carrying a CSP metacharacter never reaches the parser.[Rubric §26, Front-End Security]assesses browser-side hardening; this validator is what keeps BlazorCspSettings's contract (an absolute https origin, no path, wildcard, or delimiter) a checked invariant instead of a comment.
- Walkthrough
Validate(string? name, BlazorCspSettings options)(lines 21-31) null-guardsoptions, projects every entry that failsIsValidOrigininto a message naming the section, the entry, and the shape it must satisfy (lines 26-30), and returnsValidateOptionsResult.Successonly when that list is empty.IsValidOrigin(string? source)(lines 34-49),internal static: rejects null, whitespace-only, any entry containing whitespace, anything matchingForbiddenCharacters, or anythingUri.TryCreatecannot parse as absolute (lines 36-41). What remains must behttps(ordinal case-insensitive), have a non-empty host, and havePathAndQueryequal to exactly"/"(lines 46-48); the comment (lines 44-45) notes the forbidden set already rules out user info, a query, and a fragment, so a root-only path is the last thing separating an origin from a full URL.ToOrigin(string source)(lines 55-56),internal static:new Uri(source, UriKind.Absolute).GetLeftPart(UriPartial.Authority), the canonicalscheme://host[:port]form used to splice a validated entry into the CSP header, lower-cased host, default port dropped, no trailing slash.
- Why it's built this way: an explicit character denylist checked before parsing is stricter than trusting
Urialone, becauseUriwill happily parse strings a CSP source expression cannot safely contain; failing every bad entry at once (rather than the first) gives a deployment the whole list of what to fix in one pass. - Where it's used: registered alongside BlazorCspSettings in
MMCA.Common.UI.Web's DependencyInjection (MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs);ToOriginis reused by BlazorCspPolicyProvider'sBuildFrameSrcto canonicalize an already-validated entry.
BlazorCspPolicyProvider
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web.Security·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Security/BlazorCspPolicyProvider.cs:28· Level 2 · class (internal, sealed)
- What it is: the Content-Security-Policy provider for a Blazor Web host. It computes one CSP string at construction, pinning
connect-srcto'self'plus the configured API or Gateway origin (https and its matching WebSocket origin), optionally adding an opt-inframe-srcdirective, and hands the result to the shared security-headers middleware on every request. - Depends on: first-party: ICspPolicyProvider (the contract it implements, line 24), CspPolicy (the value it returns, a policy string plus an
Enforceflag), SecurityHeadersMiddleware (its only consumer, named in the class doc at line 12), ApiSettings (the endpoint source, injected asIOptions<ApiSettings>, line 35), and BlazorCspSettings (the frame-src source, injected asIOptions<BlazorCspSettings>, line 36) whose entries it canonicalizes with BlazorCspSettingsValidator.ToOrigin(line 94). Externals:Microsoft.Extensions.Options(IOptions<T>),Microsoft.AspNetCore.Hosting(IWebHostEnvironment),Microsoft.AspNetCore.Http(HttpContext), BCLUri. - Concept introduced, a computed CSP that fails closed.
[Rubric §26, Front-End Security]assesses whether the browser is told which origins may load scripts and open connections. A static CSP cannot express "this deployment's API origin", because that origin is configuration, so the policy is built rather than hard-coded. The two directives that matter for exfiltration are locked:script-src 'self' 'wasm-unsafe-eval'(line 99, where the WASM allowance is what lets the Blazor WebAssembly runtime instantiate) and the computedconnect-src(line 79). The load-bearing decision is what happens when the origin cannot be determined. The provider narrowsconnect-srcto'self', keeps the rest of the policy unchanged, and stays enforced (Enforce: true, line 62). A misconfigured endpoint therefore surfaces immediately as blocked API calls in the browser console rather than as a permissive header that protects nothing and that nobody notices, and the class doc states the reasoning outright: a security response header that quietly stops being enforced is the worse failure mode (lines 16-20).[Rubric §11, Security]assesses the wider defense posture; this class is one control in a chain that also includes the session-cookie auth design and the security-headers middleware, and it is deliberatelyinternal(line 24) so the only supported way to get it is the registration call, not a hand-wirednew.frame-srcis opt-in and null-safe. BlazorCspSettings's default is an emptyFrameSourceslist, andBuildFrameSrc(line 85) returnsnullwhen it is empty (lines 87-90), which is what keeps the policy byte-identical to the pre-existing baseline for every deployment that has not opted in (comment, lines 83-84).
- Walkthrough
_policy(line 32) is a singleCspPolicyfield computed once. The constructor (lines 33-43) null-guardsapiOptions,cspOptions, andenvironmentand callsBuildCsp(apiOptions.Value, BuildFrameSrc(cspOptions.Value), environment.IsDevelopment())(line 42). Because the type is registered as a singleton, this runs exactly once per process.GetPolicy(HttpContext context)(line 45) ignores the context and returns the cached policy, so the per-request cost is a field read.BuildCsp(lines 49-80) now takes the pre-computedframeSrcas a parameter and resolves the endpoint asapi.WasmApiEndpoint ?? api.ApiEndpoint(line 51). The guard on lines 55-59 rejects a blank value, a non-absolute URI, and any scheme that is not http or https; the comment on lines 53-54 records why the scheme check is not redundant: on Linux a rooted path such as/relative/pathparses as an absolutefile://URI and would otherwise sail throughUri.TryCreate. A rejected endpoint returns the enforcedconnect-src 'self'policy, still carryingframeSrc(line 62).- With a valid endpoint it derives
originviaapiUri.GetLeftPart(UriPartial.Authority)(line 66,scheme://host:port), pickswssorwsto match (line 67), and composesconnect-src 'self' {origin} {wsScheme}://{authority}(line 68). The WebSocket origin is there for the SignalR notification hub, so the live push channel is allowed without openingconnect-srcto the world. - Development only (lines 74-77) appends
http://localhost:*andws://localhost:*for Visual Studio Browser Link and Hot Reload, whose ports change per run (comment, lines 70-73); the production policy is untouched. BuildFrameSrc(BlazorCspSettings settings)(lines 85-98) is new: it returnsnullon an emptyFrameSources(lines 87-90), so the directive is omitted entirely rather than emitted empty; otherwise it maps every entry through BlazorCspSettingsValidator.ToOrigin, de-duplicates withStringComparer.OrdinalIgnoreCase(lines 92-95), and joins the result into"frame-src 'self' {origins}"(line 97). The comment (lines 82-84) notes every entry already passed the startup validator, so canonicalization here cannot fail.BuildPolicy(lines 103-112) now takesframeSrcas a parameter and assembles the directive list:default-src 'self', thescript-srcabove plus'unsafe-inline'in Development only (line 99, for the injected Hot Reload bootstrap),style-src 'self' 'unsafe-inline',img-src 'self' data: https:(line 101, deliberately open because profile pictures and content images come from arbitrary external hosts, per the comment on lines 93-96 of the class),font-src 'self', the computedconnect-src, theframe-srcdirective when non-null (line 110, spliced in with its own trailing"; "so a null value leaves the rest of the policy unchanged),base-uri 'self',form-action 'self', andframe-ancestors 'none'(line 112, clickjacking protection).
- Why it's built this way: computing once and caching keeps the hot path free, and returning a CspPolicy record rather than writing a header directly keeps the provider testable and lets one middleware own header emission. Registering it with
AddSingleton(notTryAdd) is what makes it replace the default static provider, which is why the ordering rule in the class doc (lines 21-22) matters: callAddCommonBlazorCsp()beforeAddCommonSecurityHeaders.frame-srcis threaded through as a plainstring?parameter rather than read a second time insideBuildPolicy, so the policy string is still built from one pass over its inputs. - Where it's used: registered by
AddCommonBlazorCsp()in theMMCA.Common.UI.WebDependencyInjection (MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs); the policy it returns is emitted by SecurityHeadersMiddleware. The class doc notes it was hoisted out of the app Blazor Web hosts where it had been byte-identical (line 21). - Caveats / not-in-source: the registration method's own XML doc still describes the fallback as a "permissive Report-Only fallback on misconfiguration" (
MMCA.Common.UI.Web/DependencyInjection.cs:54). The code is the truth: the fallback is enforced and narrowed to'self'(line 62). Treat that doc line as stale.
NotificationInbox
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Notifications/NotificationInbox.razor.cs:27· Level 4 · class (partial, page)
- What it is: the code-behind for the per-user notification inbox, routed at both
@page "/notifications/inbox"and@page "/notifications/inbox/{Id:int}"(NotificationInbox.razor:1-2). It fetches the signed-in user's notifications a page at a time, renders each as a read or unread card, lets the user mark items read individually or all at once, highlights and scrolls to a deep-linked notification, and reloads the current page when a real-time push asks for a refresh. - Depends on: first-party: INotificationInboxUIService (the typed read-side HTTP service), NotificationState (the per-circuit unread-count store and refresh signal), UserNotificationDTO (the row shape), IToastService (the toast abstraction), ResultUiExtensions (the
NotifyOnFailurehelper), SharedResource (the resx anchor for the localizer), ViewerTimeZone (the viewer's clock for sent times, resolved after first render), and ComponentLifetimeExtensions (theLifetimeTokenread). Externals:MudBlazor(IScrollManager,ScrollBehavior,BreadcrumbItem,Icons),Microsoft.AspNetCore.Components([Inject],[Parameter],OnInitializedAsync,OnParametersSet,OnAfterRenderAsync,InvokeAsync,StateHasChanged),Microsoft.Extensions.Localization(IStringLocalizer<T>), BCLCancellationTokenSource,IDisposable,Math.Ceiling,CultureInfo.InvariantCulture. - Concept introduced, the Blazor code-behind page pattern (
.razorplus.razor.cspartial class). The three notification pages in this unit are authored as partial classes split across two files: the.razorholds declarative MudBlazor markup and the routes, the.razor.csholds the C# (public partial class NotificationInbox, line 27), the injected services, the view state, and the handlers. The framework constructs the component, callsOnInitializedAsync(line 75) once, and re-renders when a handler mutates a field. Four habits recur across all three pages and are worth learning once here:- Disposal-safe async with a per-component
CancellationTokenSource. Areadonly CancellationTokenSource _cts(line 47) is created with the component and its token is read through_cts.LifetimeToken()(ComponentLifetimeExtensions) and passed to every service call (lines 111, 219, 271, 286, 307). Reading.Tokenoff a disposed source throwsObjectDisposedException, soLifetimeTokenreturns an already-cancelled token instead and the work stops through the normalOperationCanceledExceptionpath (ComponentLifetimeExtensions.cs:3-10,26-31).Dispose(bool)(lines 338-350) cancels and disposes it behind the classic_disposedguard (line 336). Every async handler swallowsOperationCanceledExceptionsilently (for example lines 241-244) because that is the expected outcome when the user navigates away mid-fetch. - Result-typed failures, not exceptions. The service calls return a
Result, so the page branches onTryGetValue(line 220) and routes the failure throughresult.NotifyOnFailure(Toast, L)(line 238). The comment there (lines 236-237) records the rule that makes this safe: one toast, and the list is left as it was rather than blanked, so a transient failure does not erase what is on screen. - Busy flags gate the UI.
IsLoadingandIsSaving(lines 53-54) areprotectedwith private setters; the markup shows progress while loading and setsDisabled="IsSaving"on the action controls (NotificationInbox.razor:18) so a double click cannot double-post. - Push-driven refresh via an event subscription.
OnInitializedAsyncsubscribes toNotificationState.OnRefreshRequested(line 85) andDispose(bool)unsubscribes (line 344). [Rubric §19, State Management & Data Flow]assesses where state lives; transient view state stays in private fields (_notifications,_currentPage,_totalPages, lines 56-58) while the shared unread count is written back into the scoped NotificationState (lines 289, 323) and its refresh signal is read. Local stays local, shared stays shared.[Rubric §25, Navigation, Routing & Information Architecture]assesses route structure. The second@pagedirective is a typed deep link: a push payload or an email can point straight at one notification. The class doc (lines 19-25) states the two design rules that follow from it: the:introute constraint is the validation boundary, so a malformed id never reaches the component (the router rendersNotFoundinstead), and an id that is simply not on the loaded page degrades silently to the plain inbox rather than raising an error the user can do nothing about.[Rubric §21, Accessibility]: the icon-only mark-read control carries an explicit localizedaria-label(NotificationInbox.razor:66).[Rubric §27, Internationalization & Localization]: this page holds no literal English. The injectedIStringLocalizer<SharedResource> L(line 34) resolves the title (line 49), the breadcrumbs, and every toast (L["Notif.AllMarkedRead"], line 324). The breadcrumb trail is built insideOnInitializedAsync(lines 79-83), not in a field initializer, so the injected localizer is available and labels re-resolve per circuit under the active culture (comment, lines 77-78, citing ADR-027).
- Disposal-safe async with a per-component
- Walkthrough
PageSize(line 29) isconst int 20: fixed-size server-side pagination, not infinite scroll. InjectedInboxService,NotificationState,Toast,L, MudBlazor'sScrollManager, andViewerTime(lines 31-36).[Parameter] public int? Id(line 45) is the deep-link route parameter. The doc (lines 38-44) explains the type choice:UserNotificationIdentifierTypeis anintalias, and a route parameter's type must be written out for the:intconstraint to bind, so the parameter is declaredint?and converted where it is used.- Deep-link state is four separate fields, and each exists for a distinct reason (lines 63-73):
_highlightedId(found on the loaded page),_pendingScrollId(a scroll the next render owes),_scrolledId(already scrolled, so a re-render or push-driven reload never scrolls twice), and_appliedId(theIdthe state was last computed for, to detect a re-navigation). OnParametersSet(lines 96-105) clears the_scrolledIdlatch when_appliedId != Idand then recomputes the target. The doc (lines 90-95) records the bug this prevents: navigating from/notifications/inbox/5to/notifications/inbox/9reuses the component instance, so without clearing the latch the second deep link would highlight but never move the viewport.OnAfterRenderAsync(lines 108-126) first resolves the viewer's time zone: on the first render, while the component is alive, it awaitsViewerTime.EnsureResolvedAsync(_cts.LifetimeToken())and callsStateHasChangedwhen the zone changed (lines 110-114), because sent times render on the viewer's clock and the zone is only readable once JS is available (comment, line 110). It is also the only place a scroll happens. It returns unless a scroll is pending and the component is alive, clears_pendingScrollIdand sets_scrolledIdbefore the await (comment, line 121, so a re-entrant render cannot queue the same scroll twice), and callsScrollManager.ScrollIntoViewAsync(CardSelector(id), ScrollBehavior.Smooth)(line 125).ApplyDeepLinkTarget(lines 134-148) matches the route id against what the loaded page actually holds:Id is not { } id || id <= 0 || !_notifications.Exists(n => n.Id == id)clears both the highlight and the pending scroll (lines 136-141). The doc (lines 128-133) is the "no toast" decision, a deep link the user cannot act on is not an error they caused.- The card-chrome helpers:
IsDeepLinkTarget(line 150);CardElementId(lines 152-155, formattingnotification-{id}withCultureInfo.InvariantCulturebecause it becomes a DOM id) andCardSelector(line 157);CardElevation(lines 160-168, 4 when deep-linked, else 1 for unread and 0 for read);CardClass(lines 177-183), which emits only state classes (notification-card,readorunread, plusdeep-linked). The chrome those classes select lives inwwwroot/app.css(.notification-card.unreadand.notification-card.deep-linked,app.css:519,523), built from MudBlazor palette tokens only so both themes stay legible: a primary left border marks unread, a secondary-colored ring plus a faint surface tint marks the deep link (doc, lines 170-176).[Rubric §20, Design System & Theming]shows up here: the page builds no inline styles, so its colors come from the theme's tokens in one stylesheet. - Push coalescing.
HandleRefreshRequested(lines 185-193) isEventHandler-shaped so it cannot beasync Task; it discards intoInvokeAsync(RefreshFromPushAsync)(line 192).RefreshFromPushAsync(lines 195-212) sets_refreshPending = trueand returns when a load is already in flight (lines 202-208); the comment (lines 204-205) states the invariant, never drop the push, so overlapping pushes coalesce into exactly one trailing reload instead of vanishing. LoadNotificationsAsync(lines 214-258): setsIsLoading, callsGetInboxAsync(_currentPage, PageSize, _cts.LifetimeToken())(line 219), and on success materializespage.Items(line 222), computes_totalPagesfrompage.PaginationMetadata.TotalItemCountwithMath.Ceiling(line 223) clamped to a floor of 1 (lines 224-227) so an empty inbox never renders a zero-page pager, then recomputes the deep-link highlight (line 232, only a successful load can decide whether the id is present). The tail (lines 253-257) drains_refreshPendingwith one moreRefreshFromPushAsync; the comment (lines 250-252) explains why the recursion is bounded, the flag is cleared first, so a push arriving during this reload queues one more and no further.OnPageChangedAsync(int page)(lines 260-264): records the page and reloads.MarkReadAsync(UserNotificationDTO)(lines 266-300): callsMarkReadAsync(notification.Id, _cts.LifetimeToken())(line 271), returns early on failure after a toast (lines 272-276), then optimistically patches local state, locating the row withFindIndex(line 279) and replacing it via arecord with-expression,notification with { IsRead = true, ReadOn = DateTime.UtcNow }(line 282). It then refetches the authoritative unread count (line 286) and pushes it intoNotificationState.SetUnreadCountonly when the count call succeeded (lines 287-290); a failed count means "unknown", so the badge keeps its value (comment, line 285).MarkAllReadAsync(lines 302-334): one service call (line 307), a loop flipping every unread row in place (lines 315-321), thenSetUnreadCount(0)(line 323) and a localized success toast (line 324).- Disposal:
_disposed(line 336),Dispose(bool)(lines 338-350) unsubscribing the refresh event and cancelling the_cts,Dispose()(lines 352-356) withGC.SuppressFinalize.
- Why it's built this way: the page is a thin view over INotificationInboxUIService, so all HTTP and JSON live in the service and the component stays testable against a stub. Patching local state after a mark-read (rather than refetching the page) keeps the interaction snappy while still reconciling the shared badge from the server, and the coalescing refresh keeps the list current when pushes arrive in bursts. The deep-link machinery is worth reading as a case study in idempotent side effects: a scroll is a one-shot action in a component model that re-renders freely, so it needs the "owed" and "already done" flags to be correct under re-render, re-navigation, and disposal.
- Where it's used: rendered at
/notifications/inbox(and/notifications/inbox/{Id:int}) for authenticated users; the route constant and nav entry come from NotificationRoutePaths and NotificationUIModule. NotificationBell reads the same NotificationState this page writes, and the layout-mountedNotificationListenerraises theOnRefreshRequestedsignal it consumes. Its admin siblings are NotificationList and NotificationSend.
NotificationList
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Notifications/NotificationList.razor.cs:20· Level 6 · class (partial, page)
- What it is: the code-behind for the admin/organizer push-notification history page, routed at
@page "/notifications"(NotificationList.razor:1). It loads previously sent broadcasts and renders them in a status table, with a button onward to the compose page. It renders only for an account holding thenotifications:managepermission claim; any other account gets the 403 view and the history endpoint is never called (field comment, lines 32-35). - Depends on: first-party: IPushNotificationUIService (the send and history HTTP service), PushNotificationDTO (the row shape, carrying status and recipient count), NotificationRoutePaths (the route constants), IToastService, ResultUiExtensions (
NotifyOnFailure), and SharedResource. Externals:MudBlazor(BreadcrumbItem,Icons),Microsoft.AspNetCore.Components(NavigationManager,[Inject]),Microsoft.Extensions.Localization. - Concept reinforced, the same code-behind shape as NotificationInbox. Same
[Inject]service set (lines 22-26), samereadonly CancellationTokenSource _ctsplus dispose pattern (lines 30, 108-127), sameIsLoadinggate (line 41), same cancellation-swallowing load (lines 96-99), sameresult.NotifyOnFailure(Toast, L)failure surface (line 93). It differs only in what it loads and how much of it.[Rubric §25, Navigation, Routing & Information Architecture]assesses route structure and inter-page flow; navigation goes through NotificationRoutePaths constants (NavigateToSendtargetsNotificationRoutePaths.NotificationSend, line 106) rather than a literal URL, so a route change happens in exactly one file.[Rubric §27, Internationalization & Localization]picks up an extra trick here:DisplayStatus(string status)(lines 45-50) looks upL[$"Notif.Status.{status}"]and falls back to the raw wire value whenlocalized.ResourceNotFound(line 49). The comparison value stays the untranslated wire string while only the displayed chip text localizes, which keeps transport values and presentation separate and means a newly added server status renders (untranslated) instead of blanking (the comment on line 45 cites ADR-027). This is the same pass-through-on-unknown-key discipline that DataAnnotationsModelValidator applies to error messages.
- Walkthrough
- Injected
NotificationService,NavigationManager,Toast,L,ViewerTime(lines 22-26) and a cascadingAuthenticationState(line 28);TitlereadsL["Notif.List.Title"].Value(line 37);_breadcrumbs(line 39) is built Home to Push Notifications inOnInitializedAsync(lines 62-66), with the leaf crumbdisabled: trueto mark the current page (line 65). _notificationsis anIReadOnlyCollection<PushNotificationDTO>initialized empty (line 43).OnInitializedAsync(lines 52-69) first reads the cascading auth state and sets_canManage = user.HasPermissionClaim(NotificationPermissions.Manage)(lines 54-55); when it is false it returns before building breadcrumbs or loading anything (lines 56-59), so the 403 view never triggers the history call. Otherwise it builds the breadcrumbs then awaitsLoadNotificationsAsync(line 68).OnAfterRenderAsync(lines 71-79) resolves the viewer's time zone on the first render throughViewerTime.EnsureResolvedAsync(_cts.LifetimeToken())and re-renders when it changed, because sent times render on the viewer's clock and the zone is only readable once JS is available.LoadNotificationsAsync(lines 81-104): callsGetHistoryAsync(pageNumber: 1, pageSize: 50, _cts.LifetimeToken())(line 86) and copieshistory.Itemsinto_notificationson success (line 89), otherwise raises the localized failure toast (line 93). This page fetches one fixed 50-row page and lets MudBlazor page that buffer client-side; unlike the inbox there is no server round-trip per page.NavigateToSend(line 106) sends the "send new" button to the compose page.- Disposal mirrors the family:
_disposed(line 108),Dispose(bool)cancelling the_cts(lines 110-121),Dispose()(lines 123-127).
- Injected
- Why it's built this way: broadcast history is low-volume admin data, so one 50-row fetch with client-side paging is simpler and adequate, and it avoids server-side paging plumbing that would earn nothing. Keeping HTTP behind IPushNotificationUIService mirrors the inbox and keeps the component a thin view. The shared
[Rubric §18, UI Architecture & Component Design]story is told under NotificationInbox. - Where it's used: rendered at
/notificationsfor accounts holding thenotifications:managepermission (the nav entry from NotificationUIModule carriesRequiredPermission: NotificationPermissions.Manage,NotificationUIModule.cs:20); it links onward to NotificationSend. - Caveats / not-in-source: the 50-row ceiling is a client-side choice in this file; what the server does when more than 50 broadcasts exist (whether the page silently truncates the history) is not visible here.
NotificationSend
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Notifications/NotificationSend.razor.cs:23· Level 6 · class (partial, page)
- What it is: the code-behind for the compose-and-broadcast form, routed at
@page "/notifications/send"(NotificationSend.razor:1). It collects a title and a message into NotificationSendModel, validates them against that model's own annotations, sends one broadcast through the Notification API, reports the recipient count, and returns to the history page. - Depends on: first-party: IPushNotificationUIService (the
SendAsynccall), NotificationSendModel (the form model), ModelValidation + DataAnnotationsModelValidator (the validation bridge), INotificationScopeProvider (the targeting caption), SendPushNotificationRequest (the wire contract), PushNotificationDTO (the result carryingRecipientCount), Result, NotificationRoutePaths, ErrorMessages, IToastService, ResultUiExtensions, and SharedResource. The markup also composes anUnsavedChangesGuardbound toIsDirty(NotificationSend.razor:12). Externals:MudBlazor(MudForm,BreadcrumbItem,Icons),Microsoft.AspNetCore.Components(NavigationManager,OnInitialized,OnInitializedAsync),Microsoft.Extensions.Localization. - Concept introduced,
MudFormvalidation driven entirely by the model. This is the family's form page, and it is the worked example of the validation stack in this unit. The markup declares<MudForm @ref="_form" Model="_model">with two fields that setFor,Validation="@_validate", and read their affordances off the same model (NotificationSend.razor:43-66). No rule is declared in markup: the comment above the form (lines 38-42 of the markup) spells out the division,Requireddrives the asterisk andaria-requiredwhile its message still comes from the model,MaxLengthcaps the input, andCountershows the budget from the shared request contract so the numbers cannot drift from the server. The C# holds the form by reference (MudForm? _form, line 47) and explicitly drives validation before sending:await _form.ValidateAsync()then a guard on_form.IsValid(lines 122-129). MudForm has noOnValidSubmit, so that explicit pass is the gate (comment, lines 120-121).[Rubric §24, Forms, Validation & UX Safety]assesses input validation, double-submit protection, and feedback. All four are present: model-declared rules run through the shared delegate, anIsSavingflag (line 44) bound toDisabledon both buttons (NotificationSend.razor:73,80) so the send cannot be fired twice (backed in code by a re-entrancy guard,_form is null || IsSaving, raised before the first await at lines 109-113: each send mints its own idempotency key, so a second click that slipped in while the first was still validating would broadcast twice), a warning toastErrorMessages.ValidationErroron a failed gate (line 127), and a success toast naming the recipient count (line 137).- Two failure surfaces, deliberately not one.
_sendResult(line 53) holds the last attempt's outcome and is rendered inline by the sharedErrorSummarycomponent (NotificationSend.razor:36), while the toast stays the transient cue. The markup comment (lines 31-35) records the design constraint: the summary is deliberately not fedMudForm.Errors, because every field already renders its own message inline and MudBlazor contributes a generic "Required" of its own that would stack on top of the model's wording._sendResultis nulled at the start of every attempt (line 118), so the summary never shows a stale failure. [Rubric §21, Accessibility]and[Rubric §18, UI Architecture & Component Design]meet in theErrorSummary: a failure that only appeared as a toast would time out on a long form and be unrecoverable for a screen-reader user who missed it (comment, lines 146-147).[Rubric §27, Internationalization & Localization]: every string resolves throughIStringLocalizer<SharedResource> L(line 28), including the success messageL.Plural("Notif.Send.SentTo", sent.RecipientCount, sent.RecipientCount)(line 137), which passes the count through the shared plural extension so the resource file (not C#) picks the plural form as well as the word order. As with its siblings the breadcrumb trail is built in an initialization hook, here the synchronousOnInitialized(lines 66-78, comment citing ADR-027).[Rubric §24, Forms, Validation & UX Safety]again, for the navigate-away guard:Sent(a private bool, set true immediately before the post-sendNavigateTo) andIsDirty(true wheneverSentis false and either field is non-blank) feed the markup'sUnsavedChangesGuard, so leaving with typed-but-unsent text prompts, while the send's own redirect never does. The comment on theSentsetter records the ordering: it is set before the navigation call, because the guard reads the live accessor.
- Walkthrough
- Injected
NotificationService,NavigationManager,Toast,L, andScopeProvider(lines 25-29), plus a cascadingAuthenticationState(line 31);_cts(line 33);_canManage(line 38);Title(line 40);_breadcrumbs(line 42) built Home to Push Notifications to Send (lines 69-74), where the middle crumb is a real link viaNotificationRoutePaths.Notifications(line 72) and the leaf isdisabled: true(line 73). _model(line 46) is areadonly NotificationSendModelcreated with the component;_validate(line 57) is the singleFunc<object, string, IEnumerable<string>>MudBlazor calls with(model, member path), wired inOnInitializedasModelValidation.For(_model, new DataAnnotationsModelValidator(L))(line 77). One delegate serves both fields, and no rule is written twice (comment, lines 55-56).OnInitializedAsync(lines 80-105) first reads the cascading auth state and sets_canManage = user.HasPermissionClaim(NotificationPermissions.Manage)(lines 82-83); when it is false it returns before touching the scope provider (lines 84-87), and the page renders the 403 view instead of the compose form (field comment, lines 35-38). Otherwise it resolves the optional scope caption:ScopeProvider.GetCurrentScopeDisplayNameAsync(_cts.LifetimeToken())(line 95), and only when the name is non-blank does it build_scopeCaption(lines 96-99). The field doc (lines 59-64) and the comment (lines 89-92) give the reasoning: a scoped application applies its scope to the send automatically, so without a caption the operator would be composing a broadcast with no visible statement of who receives it, and when there is no scope the page renders no caption at all rather than an empty line.[Rubric §24, Forms, Validation & UX Safety]again: making an implicit targeting decision visible is part of a safe destructive-ish action.SendNotificationAsync(lines 107-159): returns at once when_formis null or a send is already in flight (lines 109-113), setsIsSaving(line 115), and inside thetryclears_sendResult(line 118) and validates, warning on failure (lines 122-129), so a failed validation also releases the flag infinally; then buildsnew SendPushNotificationRequest(_model.Title, _model.Body)(line 131) and awaitsSendAsync(request, _cts.LifetimeToken())(line 132), storing the result for the summary (line 133). On a non-null PushNotificationDTO it raises the success toast withsent.RecipientCount(line 137), setsSent = true(line 141), and navigates back to the list; otherwiseresult.NotifyOnFailure(Toast, L)(line 148). The cancellation catch (lines 151-154) additionally names theInteractiveAutorender-mode transition, the case where the WebAssembly runtime takes over mid-call;IsSavingis cleared infinally(lines 155-158).NavigateToList(line 161) is the Cancel button's handler, back toNotificationRoutePaths.Notifications.Sent(a private bool property) andIsDirty(!Sent && (Title or Body non-whitespace)) are declared just afterNavigateToList, immediately above the disposal members;Sentis documented as being set the moment a send has succeeded and the page is about to navigate away on its own, so the unsaved-changes guard does not prompt over work that has just left the building.- Disposal mirrors the family:
_disposed(line 177),Dispose(bool)(lines 179-190),Dispose()(lines 192-196).
- Injected
- Why it's built this way: a deliberately small form that still demonstrates the full pattern. The rules live on NotificationSendModel so the client cap, the client message, and the server invariant all read the same constants; HTTP stays behind IPushNotificationUIService so the component is unit-testable; and the
Sent/IsDirtypair keeps the unsaved-changes guard from firing on the page's own post-send redirect, which is the one navigation on this page that is not the user abandoning work. The send is fire-and-confirm: the server fans out to recipients through the push pipeline (see Group 10) and returns only the aggregate count. - Where it's used: rendered at
/notifications/sendfor accounts holding thenotifications:managepermission (the same gate as the navigation entry,NotificationUIModule.cs:20; other accounts get the 403 view), reached from the button on NotificationList. The server-side validator for SendPushNotificationRequest enforces the same rules a second time, so client validation is a UX affordance rather than the security boundary.
ServerTokenStorageService
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web.Services·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Services/ServerTokenStorageService.cs:30· Level 13 · class (sealed)
- What it is: the Blazor Server implementation of ITokenStorageService: a cookie-only token store with no
localStorage. During SSR prerender it reads the access token from the HttpOnly session cookie; on the live interactive circuit it holds the access token in memory only and re-acquires it from those cookies through a same-origin refresh endpoint. The refresh token is never readable from JavaScript. - Depends on: first-party: ITokenStorageService (the contract, line 35), CookieTokenReader (reads the access and refresh cookies off the request), ISessionCookieSync (seeds and clears the HttpOnly cookies, and reports whether the write succeeded), ITokenRefresher (acquires a fresh access token from
/auth/session/token), JwtTokenInfo (client-side freshness check), and ISessionAwareTokenRefresher with its TokenAcquisition result (AccessToken,IsUnavailable), the optional refresher capability that tells a definitive "no session" from an unavailable one (ServerTokenStorageService.cs:153-156). Its WASM sibling is WasmTokenStorageService (named in the class doc, line 15). Externals:Microsoft.AspNetCore.Http(IHttpContextAccessor,HttpContext), BCLLock,TimeProvider,Task,TimeSpan,InvalidOperationException. - Concept introduced, the two-world token store (SSR request versus interactive circuit). A Blazor Web page runs twice: first as a server-side prerender inside a live HTTP request, where an
HttpContextexists and JS interop does not, and then as a stateful circuit with noHttpContext. One store has to serve both worlds, and this class branches onhttpContextAccessor.HttpContext is not null(line 70) to decide which source of truth applies.- SSR prerender (lines 70-73): the request's HttpOnly cookie wins, read via
cookieTokenReader.ReadAccessToken()(line 72), because the middleware may have just refreshed it in place on this navigation (comment, lines 68-69). - Interactive circuit (lines 75-112): the token lives in the
_accessTokenfield (line 45). IfJwtTokenInfo.IsFresh(_accessToken, ExpirySkew)(line 76) says it survives the 30-second skew (line 37), it is returned as is; otherwise it is re-acquired, unless a recent definitive "no session" answer is still remembered (the anonymous grace, next). [Rubric §26, Front-End Security]assesses how credentials are held in the browser. This is a deliberate XSS-hardening design: the long-lived refresh token stays in an HttpOnly cookie unreachable from script, the access token exists only in circuit memory and is never persisted, and the refresh token transits JS exactly once, for the same-origin POST that seeds the cookies at login (SetTokensAsync, lines 122-135).[Rubric §11, Security]assesses the wider auth model; this store is one edge of the browser session-cookie design (ADR-022) and of the client token lifecycle (ADR-051), the piece that decides where a bearer token is read on each side of the prerender boundary.- Login fails loudly, logout stays best-effort. The two cookie writes are deliberately asymmetric.
SetTokensAsyncthrowsInvalidOperationException("The session cookie could not be written.")whenSyncAsyncreturnsfalse(lines 131-134), after the in-memory token is already set, so AuthUIService reportsAuth.TokenStorageUnavailableinstead of a login that looks successful and then silently signs out at the first access-token expiry, when no cookie exists to refresh from (comment, lines 127-130).ClearTokensAsyncdiscards the boolean (_ = await sessionCookieSync.ClearAsync(), line 143), because logout is best-effort by contract (IAuthUIService.LogoutAsyncnever fails); a caller that needs proof the session ended usesIAuthUIService.RevokeAllSessionsAsync(comment, lines 140-142). - Anonymous grace, so a signed-out visitor costs one token round trip, not one per API call.
AnonymousGraceis a 15-secondstatic readonly TimeSpan(line 40). When a hydrate returns no token and the refresher did not report itself unavailable,HydrateAsyncsets_anonymousUntil = now + AnonymousGrace(lines 164-167);GetAccessTokenAsyncthen answers null from memory while_accessToken is nulland the clock is before_anonymousUntil(lines 81-84).SetTokensAsyncresets_anonymousUntil(line 125) so a login right after anonymous browsing does not wait out the grace, and a session created in another tab is seen within the window (class doc, lines 17-23). Only a DEFINITIVE answer starts it: a refresher implementingISessionAwareTokenRefresherreports a 429, a 5xx, a dropped connection or unavailable interop as unavailable (comment, lines 148-151), so the next read hydrates again instead of treating a signed-in user as anonymous; a refresher without that capability keeps the earlier reading, where null means "no session" (lines 159-162).: a Blazor circuit is genuinely multi-threaded, and several consumers (the delegating handler, the auth-state provider, the SignalR connection) can all miss the cache at once.
- SSR prerender (lines 70-73): the request's HttpOnly cookie wins, read via
- Walkthrough
- The primary constructor (lines 30-35) takes
IHttpContextAccessor, CookieTokenReader, ISessionCookieSync, and ITokenRefresher, and a nullableTimeProvider. A public four-argument constructor (lines 49-64) chains to it withtimeProvider: null, kept so code compiled against the original signature keeps binding (doc, lines 49-52). The type issealedand carries no app-specific state, which is why it could be hoisted out of both app hosts (class doc, lines 14-16). - Fields:
ExpirySkew(line 37, astatic readonly TimeSpanof 30 seconds),AnonymousGrace(line 40, 15 seconds), theLock _hydrateSync(line 42, the .NET 9+ dedicated lock object),_timeProvider(line 43,timeProvider ?? TimeProvider.System), the in-memory_accessToken(line 45),_hydrateInFlight(line 46), the shared acquisition task, and_anonymousUntil(line 47), the end of the remembered "no session" answer. GetAccessTokenAsync(lines 66-113): the SSR/circuit branch, the freshness check, the anonymous-grace short-circuit (lines 81-84), then the single-flight guard._hydrateInFlight ??= HydrateAsync()executes insidelock (_hydrateSync)(lines 91-95) and the resulting task is copied to a local before the lock is released. The comment on lines 86-89 records exactly why the naive unguarded??=was not enough: two callers could each start a hydrate and the later completion would overwrite the other's token;HydrateAsyncreaches its first await immediately, so nothing slow runs under the lock. Thefinally(lines 101-112) clears_hydrateInFlightonly when it is still reference-equal to the task this caller awaited (line 107), so a newer hydrate started after this one completed is not dropped, which would split the next set of callers again.GetRefreshTokenAsync(lines 115-120): returnscookieTokenReader.ReadRefreshToken()during SSR andnullon the circuit, because the HttpOnly refresh cookie is unreadable there; it wraps the value inTask.FromResultrather than beingasync(no await needed).SetTokensAsync(lines 122-135): resets_anonymousUntil(line 125), caches the access token in memory (line 126), then awaitssessionCookieSync.SyncAsync(accessToken, refreshToken)and throws when it reports failure (lines 131-134).ClearTokensAsync(lines 137-144): nulls the in-memory token (line 139) and awaitssessionCookieSync.ClearAsync()with its result explicitly discarded (line 143).HydrateAsync(lines 146-170): the private acquisition. When the refresher is anISessionAwareTokenRefresherit callsTryAcquireAccessTokenAsync()(line 155) and keeps both the token and theIsUnavailableflag; otherwise it callsAcquireAccessTokenAsync()(line 161). A null token withunavailablefalse starts the grace (lines 164-167); the token is cached and returned (line 169). Every await on the hydrate path usesConfigureAwait(false)(lines 99, 155, 161), the library posture the cookie-write awaits do not take.
- The primary constructor (lines 30-35) takes
- Why it's built this way: Blazor Server's split lifecycle breaks the naive "read a token from storage" store, which would either fail during prerender (no JS) or leak the refresh token to script if it used
localStorage. Branching onHttpContextpresence and keeping the refresh token cookie-only resolves both. The locked single-flight is the correction of a real concurrency defect in the simpler??=version, and it is worth reading as a small case study in why "good enough" atomicity on a circuit is not good enough. Surfacing a failed cookie write at login, while tolerating one at logout, puts the failure where the user can still act on it. The 15-second anonymous grace trades a short staleness window for not paying a token round trip on every API call of a signed-out visitor, and it never remembers a transient failure. See ADR-022. - Where it's used: registered as the scoped ITokenStorageService by
AddCommonServerTokenStorage()in theMMCA.Common.UI.WebDependencyInjection (MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs:41-51); consumer hosts call that instead of shipping their own copy. The same call registers BrowserOriginHandler on the"APIClient"pipeline (lines 47-48). Pinned byServerTokenStorageServiceTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Web.Tests/Services/ServerTokenStorageServiceTests.cs). - Caveats / not-in-source: the cookie names, lifetimes, and the
/auth/session/tokenendpoint itself are not in this file; they live in theMMCA.Common.APIsession-cookie plumbing referenced by the doc comment (lines 9-23).
AuthenticatedServiceBase
MMCA.Common.UI ·
MMCA.Common.UI.Services.Api·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/AuthenticatedServiceBase.cs:16· Level 1 · class (abstract)
- What it is: the base class every UI-side HTTP service inherits. It supplies one shared Polly
retry policy, two ways to get an
HttpClientwith a bearer token already attached, and the idempotency-key generator that makes those retries safe. - Depends on: ITokenStorageService, imported from the token namespace
MMCA.Common.UI.Services.Auth.Tokens(AuthenticatedServiceBase.cs:4); its documentation also names AuthDelegatingHandler as the thing it deliberately bypasses andAuth.Tokens.ITokenRefresher, that is ITokenRefresher, as the source of the replay token. Externals:IHttpClientFactory,PollyandPolly.Retry. - Concept introduced, why a base class and not just the handler pipeline.
[Rubric §29, Resilience & Business Continuity]assesses whether transient failures are absorbed rather than surfaced; the retry policy is that. The sharper teaching is in theCreateAuthenticatedClientAsyncdoc comment (AuthenticatedServiceBase.cs:53-58):IHttpClientFactorycreates its handlers in a separate DI scope from the Blazor circuit, so aDelegatingHandlercannot reach the circuit'sIJSRuntimeto read the in-memory access token. The base class works around that by reading the token from the circuit-scoped storage service itself and setting the header directly.[Rubric §9, API & Contract Design]covers the idempotency half: retrying a POST is only safe if the server can recognize the repeat, which is what ADR-017 provides. - Walkthrough
RetryPolicyis aprotected static readonly AsyncRetryPolicy<HttpResponseMessage>built once from the shipped backoff (AuthenticatedServiceBase.cs:27), so one policy instance serves the whole app rather than one per service instance per circuit.ApiClientNamepins the named client to"APIClient"(AuthenticatedServiceBase.cs:29), and both constructor arguments are null-checked into private fields (AuthenticatedServiceBase.cs:31-32).SharedRetryPolicy(AuthenticatedServiceBase.cs:38) is aninternal staticgetter that returns that sameRetryPolicyinstance. Its doc comment (AuthenticatedServiceBase.cs:34-37) names the only reader, IdempotentReadRetry, which reuses the exact policy object without inheriting from this class, so the anonymous read path and the authenticated one cannot drift apart.NewIdempotencyKey()(AuthenticatedServiceBase.cs:51) returns a compactGuid.NewGuid().ToString("N"), and its remarks (AuthenticatedServiceBase.cs:43-49) carry the load-bearing rule: the value is generated once per logical operation and reused across every retry attempt, because the server-side idempotency filter keys its cached response off it. Generating a new key per attempt would defeat the dedup entirely and let a retry create a duplicate record.CreateAuthenticatedClientAsync()(AuthenticatedServiceBase.cs:59) resolves the"APIClient"(AuthenticatedServiceBase.cs:61), reads the token (AuthenticatedServiceBase.cs:65), setsAuthorization: Bearerwhen non-blank (AuthenticatedServiceBase.cs:66-70), and catchesInvalidOperationExceptionto proceed without a token during SSR prerender, when JS interop is unavailable (AuthenticatedServiceBase.cs:72-75).CreateClientWithToken(string accessToken)(AuthenticatedServiceBase.cs:88) is the replay path. Its doc comment (AuthenticatedServiceBase.cs:80-86) explains why it exists: after the API answers401, the stored token still looks fresh by the client clock, so re-reading storage would just resend the token the server has already rejected. The caller passes the token it acquired straight from ITokenRefresher; the method rejects a blank one (AuthenticatedServiceBase.cs:90) and stamps the header unconditionally (AuthenticatedServiceBase.cs:93).IsRetryableResponse(AuthenticatedServiceBase.cs:108) is the retry predicate. It first excludes501 Not Implementedand505 HTTP Version Not Supported(AuthenticatedServiceBase.cs:110-113) because, as the remarks say (AuthenticatedServiceBase.cs:102-107), those are permanent verdicts and retrying only burns the budget and delays the error the caller needs to see. It then asksIsReplaySafewhether the request may be sent again and gives up if not (AuthenticatedServiceBase.cs:115-118). Only then does it accept anything>= 500plus408 Request Timeoutand429 Too Many Requests(AuthenticatedServiceBase.cs:120-121), the two codes where the server is explicitly inviting a later attempt.IsReplaySafe(HttpRequestMessage? request)(AuthenticatedServiceBase.cs:134) is the gate that keeps a retry from duplicating a write. Its doc comment (AuthenticatedServiceBase.cs:124-131) states the rule: GET, HEAD, PUT, DELETE and the other idempotent verbs are always safe to re-send, while a POST or PATCH is safe only when it carries a non-blankIdempotency-Keyheader, which an[Idempotent]endpoint deduplicates on. Without a key, a failed attempt may still have run server-side, and a retry can hit a rate limit and replace the server's real failure with a 429. A response with no request attached is treated as safe (AuthenticatedServiceBase.cs:136-139). The method isinternal staticso a test can pin it directly, and the doc comment onRetryPolicy(AuthenticatedServiceBase.cs:22-26) carries the same POST/PATCH caveat.DefaultBackoff(AuthenticatedServiceBase.cs:153-155) is the shipped schedule:2^attemptseconds (2s, 4s, 8s) plus up to one second of random jitter, so a fleet of clients does not re-converge on the same instant. TheS2245/CA5394suppression around it (AuthenticatedServiceBase.cs:150) documents that the randomness only spaces retries and feeds no security decision.BuildRetryPolicy(Func<int, TimeSpan> backoff)(AuthenticatedServiceBase.cs:170-173) isinternaland backoff-injectable so a test can exercise the disposal contract without waiting out the real delays. ItsonRetrydisposes the retried attempt's response (AuthenticatedServiceBase.cs:173); the remarks (AuthenticatedServiceBase.cs:162-169) explain that Polly hands the caller only the final outcome, so without this every intermediate 5xx, 408 or 429 response leaks its content buffer and keeps its connection out of the handler pool until finalization, exactly under the sustained backend failure the retries exist to survive. A retriedHttpRequestExceptioncarries no result, hence the null-conditional, and the final response is not disposed here because the caller owns it.
- Why it's built this way: the DI scope mismatch is a real Blazor Server constraint, not a preference, so the workaround has to live somewhere every service shares. The retry ceiling and jitter line up with the resilience posture in ADR-009.
- Where it's used: inherited by
EntityServiceBase<TEntityDTO, TIdentifierType>
(
MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/EntityServiceBase.cs:47), ChildEntityServiceBase (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/ChildEntityServiceBase.cs:22) and NotificationInboxService (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/NotificationInboxService.cs:34), and through them by every module-level UI service in the consumer apps. The members show up asNewIdempotencyKey()on writes (EntityServiceBase.cs:174),RetryPolicy.ExecuteAsyncaround each call (EntityServiceBase.cs:365andEntityServiceBase.cs:393) andCreateClientWithTokenon the 401 replay (NotificationInboxService.cs:151). The policy itself is also reached without inheritance by IdempotentReadRetry (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/IdempotentReadRetry.cs:24), which gives anonymous lookup services the same retries on GETs.
IdempotentReadRetry
MMCA.Common.UI ·
MMCA.Common.UI.Services.Api·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/IdempotentReadRetry.cs:18· Level 2 · class (static)
- What it is: the retry that AuthenticatedServiceBase applies, made
reachable without inheriting from it, so a service that makes anonymous reads (a lookup service that
creates a plain
APIClient) gets three retries instead of a single attempt (class summary, lines 5-8). - Depends on: AuthenticatedServiceBase for the policy instance, read
through its
internalSharedRetryPolicy(line 24,MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/AuthenticatedServiceBase.cs:38). Externals:Polly.Retry(AsyncRetryPolicy<HttpResponseMessage>, line 1) and BCLHttpClient. - Concept introduced, retry is only safe for an idempotent verb.
[Rubric §29, Resilience, Reliability & Business Continuity]assesses whether transient failures are absorbed rather than surfaced on every call path, not just the inherited one.[Rubric §9, API & Contract Design]covers the limit: the class remarks (lines 9-16) state that the policy re-sends on anHttpRequestException, a 5xx other than 501/505, 408 or 429, up to three times with 2s, 4s, 8s backoff plus jitter, and that re-sending is only safe when doing the operation twice is the same as doing it once. That is why the only entry point is a GET; a write that needs retries goes through EntityServiceBase<TEntityDTO, TIdentifierType>, whose creates carry anIdempotency-Keythe server deduplicates on. - Walkthrough
Policy(line 24) is aninternal staticgetter returningAuthenticatedServiceBase.SharedRetryPolicy. Its doc comment (lines 20-23) stresses that it is the same object, not a copy built from the same settings, so the two paths cannot drift.GetAsync(HttpClient httpClient, Uri requestUri, CancellationToken cancellationToken)(line 35) null-guards both arguments (lines 40-41) and returnsPolicy.ExecuteAsync(token => httpClient.GetAsync(requestUri, token), cancellationToken)(line 43), so the token is honored between attempts and by each send. The doc comment (lines 26-34) sets the ownership rule: every retried response is disposed by the policy, the caller owns and disposes the final one, andHttpRequestExceptionescapes only when every attempt failed without a response.
- Why it's built this way: sharing the instance rather than re-declaring the policy keeps one
definition of "retryable" and one backoff schedule for the whole UI layer, including the
response-disposal behavior of
AuthenticatedServiceBase.BuildRetryPolicy. Exposing a GET-only surface instead of a generalExecuteAsyncis the guardrail: the type cannot be used to retry a non-idempotent write by accident. - Where it's used: ADC lookup services that read anonymously, for example
SpeakerLookupService(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Speakers/SpeakerLookupService.cs:96),EventLookupService(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Events/EventLookupService.cs:102),CategoryItemLookupService(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Categories/CategoryItemLookupService.cs:54),SessionLookupService(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Lookups/SessionLookupService.cs:26,57) andNowNextService(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/HappeningNow/NowNextService.cs:26). Several of them pass it as the per-page fetch inside PagedReadAll. Pinned byIdempotentReadRetryTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Api/IdempotentReadRetryTests.cs).
ClientConfigBuilder
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web.ClientConfig·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/ClientConfig/ClientConfigBuilder.cs:17· Level 2 · class (sealed)
- What it is: the accumulator a host's own
extrasdelegate writes into when it wants extra data served on the anonymous/client-configdocument, one named section perAddcall. - Depends on: ClientConfigEndpointExtensions (its
ApiSectionNameconstant, checked as the one reserved name,ClientConfigBuilder.cs:57);Microsoft.AspNetCore.Http(HttpContext, BCL) andMicrosoft.Extensions.DependencyInjection/Microsoft.Extensions.Configurationfor the two convenience accessors it republishes. - Concept, a builder with an internal constructor.
[Rubric §26, Front-End Security]covers what this type prevents: the constructor isinternal(line 29), so the only place aClientConfigBuildercan come into existence is insideMapClientConfigEndpoint's own request delegate, which is also the one place a host'sextrascallback runs. A host cannot construct one ahead of time and stash arbitrary state in it outside the request it belongs to. - Walkthrough
_sections(line 27) is aDictionary<string, object?>keyed withStringComparer.OrdinalIgnoreCase, so"OAuth"and"oauth"collide as the same section.HttpContext(line 32) and the two accessors built on it,Services(line 35, readsHttpContext.RequestServices) andConfiguration(line 38, resolvesIConfigurationfrom those services), let anextrasdelegate reach DI and configuration without the endpoint having to pass them separately.Add(string name, object? value)(line 53) blank-checksname(ArgumentException.ThrowIfNullOrWhiteSpace, line 55), rejects the reservedClientConfigEndpointExtensions.ApiSectionNamecase-insensitively (lines 57-62), then adds through_sections.TryAdd(line 64) so a second call with the same name throws rather than silently overwriting the first (lines 65-67). It returnsthisfor chaining (line 69).Sections(line 40) isinternal, anIReadOnlyDictionary<string, object?>view the endpoint reads back after the delegate returns; nothing outside this package can enumerate a builder's contents.
- Why it's built this way: the reserved-name and duplicate-name guards live on the builder rather
than being left to the endpoint's own loop, so every caller of
Add, present or future, gets the same two checks for free. The internal constructor and internalSectionsgetter keep the type's read side as narrow as its write side: a host can only add, never inspect what a different extras call already added. - Where it's used: constructed once per request inside
ClientConfigEndpointExtensions
.MapClientConfigEndpoint's handler and handed to the caller'sextrasdelegate (MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/ClientConfig/ClientConfigEndpointExtensions.cs:63-64); itsSectionsare then read back to assemble the response document (ClientConfigEndpointExtensions.cs:73-76). Exercised directly byClientConfigEndpointTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Web.Tests/ClientConfig/ClientConfigEndpointTests.cs).
ClientConfigEndpointExtensions
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web.ClientConfig·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/ClientConfig/ClientConfigEndpointExtensions.cs:17· Level 2 · class (static)
- What it is: the extension that maps
GET /client-config, the anonymous document a WASM client's bootstrap fetches to learn the browser-reachable API base address before it can hold a session, plus whatever extra sections the host contributes. - Depends on: ClientConfigBuilder (constructed per request, line 63),
ApiSettings (
IOptions<ApiSettings>, read forWasmApiEndpoint, line 55), and, only in a host running the same-origin API proxy, SameOriginApiProxyMarker (probed from request services, line 93) and SameOriginApiProxySettings (read forPathPrefix, line 98); externalsMicrosoft.AspNetCore.Routing(IEndpointRouteBuilder,RouteHandlerBuilder),Microsoft.AspNetCore.Http(HttpContext),System.Text.Json(JsonNamingPolicy.CamelCase), and the ASP.NET Core minimal-APIResultstype. - Concept introduced, fail-closed instead of falling back.
[Rubric §22, Configuration and Secrets Management]assesses whether a bad or missing configuration value is caught rather than silently degrading a live host. The endpoint's remarks (lines 33-40) state the rule directly: it servesApi:WasmApiEndpoint, the browser-reachable gateway URL, and never falls back toApi:ApiEndpoint, the server head's own service-discovery name, because a browser cannot resolve that name and serving it would hand the client a silently broken base address. A host missing the key answers every request with a server error naming the missing key (lines 56-61), which surfaces as a failed client start instead of a client quietly pointed at the wrong API. - Concept introduced, anonymous by declared necessity.
[Rubric §11, Security]covers why this endpoint is one of the few that must allow anonymous access on purpose: the WASM client fetches this document before it can hold a session (remarks, lines 41-46), and a host's fallback authorization policy would otherwise gate it like any other endpoint that states nothing. The endpoint therefore declaresAllowAnonymous()itself (line 80) rather than relying on an ambient default, and the remarks warn that only values the public site renders anyway belong inextras(lines 44-45). - Concept introduced, an additive key for the same-origin proxy. A host that runs the same-origin
API proxy (ADR-131) gets one
extra member in the
Apisection,sameOriginApiEndpoint, the proxy's origin-relative base (for example/api/) that the WASM bootstrap resolves against the page's own origin, whileapiEndpointstays the gateway for full-page navigations (doc, lines 84-90). The proxy's presence is detected from DI (the marker service), not from configuration, so every host without the proxy serves the section exactly as before and an existing client sees no change. - Walkthrough
- Two public constants:
ClientConfigPath = "/client-config"(line 20) andApiSectionName = "Api"(line 23), the one section name ClientConfigBuilder treats as reserved. - The
extension(IEndpointRouteBuilder endpoints)block (line 25, see primer) holds the single memberMapClientConfigEndpoint(Action<ClientConfigBuilder>? extras = null)(line 52). - The handler (lines 53-79) takes the request's
HttpContextplusIOptions<ApiSettings>, readsapiSettings.Value.WasmApiEndpoint(line 55), throwsInvalidOperationExceptionnaming the missing key when it is blank (lines 56-61), builds a ClientConfigBuilder for the request and invokesextrason it (lines 63-64), seeds the response with the camelCasedApisection produced byBuildApiSection(context, wasmApiEndpoint)(lines 68-71), then overlays every builder section, each name run through the sameJsonNamingPolicy.CamelCasepolicy the web JSON defaults apply to members (lines 73-76), so the document's shape matches what an anonymous-object payload would have produced anyway. - The route is finished with
.AllowAnonymous().ExcludeFromDescription()(lines 80-81): anonymous for the reason above, and excluded from the OpenAPI description because it is not a data endpoint a client integrates against, it is bootstrap plumbing. - The private
BuildApiSection(HttpContext, string)(lines 91-100) returnsnew { ApiEndpoint }whenGetService<SameOriginApiProxyMarker>()is null (lines 93-96); otherwise it readsIOptions<SameOriginApiProxySettings>.Value.PathPrefix(line 98) and returnsnew { ApiEndpoint, SameOriginApiEndpoint = prefix + "/" }(line 99).
- Two public constants:
- Why it's built this way: putting the base-address selection, the reserved-name policy and the
camelCase conversion all in one mapped endpoint means every host that calls
MapClientConfigEndpointgets the identical fail-closed contract; a host only supplies the pieces specific to it throughextras. The same-origin key is decided here too, from the proxy's own DI marker, so a host that adds the proxy does not have to remember a second client-config change. - Where it's used: called from a host's endpoint mapping alongside the other framework endpoints;
its handler constructs ClientConfigBuilder
(
MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/ClientConfig/ClientConfigBuilder.cs, 3 call sites) and is pinned byClientConfigEndpointTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Web.Tests/ClientConfig/ClientConfigEndpointTests.cs, 2 call sites).
HttpResultExecutor
MMCA.Common.UI ·
MMCA.Common.UI.Services.Api·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/HttpResultExecutor.cs:35· Level 3 · class (static)
- What it is: the wrapper every UI service call runs inside. It converts the faults that survive
after a response has been handled, a refused connection, a DNS failure, a broken stream, a client
timeout, into failed Result values, so a service method
typed as returning a
Resultreally does return one. - Depends on: Result and its generic sibling
Result<T>, plus Error for the failure it mints (HttpResultExecutor.cs:2,133,136);System.Text.JsonandSystem.Net.Http(BCL) plusPolly(HttpResultExecutor.cs:3) for the fault set it recognizes. It isstaticand holds no state, so anything can call it, including services that derive from none of this package's bases. - Concept introduced, the two halves of "a client call never throws".
[Rubric §9, API & Contract Design]assesses whether the error channel between client and server is explicit and typed. The framework's answer has two pieces and this is the second one. The first, ProblemDetailsResultReader, converts a response: it reads the server's ProblemDetails body back into errors with the original ErrorType intact. This class converts the absence of a response, and the class comment states the split outright (lines 12-17). Only with both does a page get to branch on aResultinstead of writing acatch, which is what ADR-013 asks for (Website/docs-src/adr/013-result-pattern.md) and what ADR-094 records as the client contract (Website/docs-src/adr/094-client-entity-data-access.md:82-94).[Rubric §29, Resilience, Reliability & Business Continuity]applies because this is the boundary where an infrastructure fault stops being an exception and becomes something the UI can render. - Concept introduced, cancellation is not a failure.
[Rubric §24, Forms, Validation & UX Safety]assesses whether the user-facing outcome of an interaction is honest. A page cancels its own work all the time: a disposed component, a grid fetch superseded by the next keystroke. Reporting that back as an error would paint a message for something the user never did. The class therefore distinguishes two identically typed exceptions by inspecting the token, and documents the rule on itself (lines 18-24). - Walkthrough
- Two public constants name the failures it can mint,
TransportErrorCode = "Http.TransportFailure"(line 38) andTimeoutErrorCode = "Http.Timeout"(line 41). They are public because they are the branch a page uses when it needs different wording; the two English messages areinternalconstants (lines 43-47), internal rather than private so that ResultUiExtensions can recognize the executor's own sentence (MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/ResultUiExtensions.cs:400-405). ExecuteAsync(Func<Task<Result>>, CancellationToken)(line 56) and its generic twinExecuteAsync<T>(Func<Task<Result<T>>>, CancellationToken)(line 91) are the whole public surface. Both are structurally identical, and both start withArgumentNullException.ThrowIfNull(lines 58,- and
cancellationToken.ThrowIfCancellationRequested()(lines 63, 98). The pre-check is explained in the source (lines 60-62): an already-abandoned call must never reach the network, and the propagation contract has to hold even for an operation that would complete without ever observing the token.
- and
- The
trysimply awaits the caller's operation (lines 67, 102). Everything interesting is in the three catch clauses, and their order is the design. catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)rethrows (lines 69-72, 104-107). This filter is the entire mechanism: the caller's token being cancelled is what identifies the exception as the caller's own.- The unfiltered
catch (OperationCanceledException)(lines 73, 108) is therefore the other case:HttpClientgave up on its own timeout, which raises the same exception type with the token not cancelled. That one becomes a failure carryingTimeoutError()(lines 75, 110). catch (Exception exception) when (IsTransportFault(exception))(lines 77, 112) filters rather than catching broadly, so nothing outside the recognized set is swallowed.IsTransportFault(line 127) admits four types:HttpRequestException,IOException,JsonExceptionand Polly'sExecutionRejectedException(line 128), the base ofTimeoutRejectedExceptionandBrokenCircuitException, so a client resilience pipeline that refuses to run or finish the call also becomes a failure (HttpResultExecutor.cs:3importsPolly). The comment above it draws the line explicitly (lines 118-126): anything else is a programming fault and keeps travelling as an exception.TransportError(line 130) puts the exception's own text on the error'sSourcerather than itsMessage(lines 131-133), because that text is diagnostic detail: not localizable, and not safe to render verbatim.TimeoutError(line 135) carries no detail at all.
- Two public constants name the failures it can mint,
- Why it's built this way: the class comment records the third consequence of the split (lines
25-33): a transport failure never reached a server, so nothing localized it on the way back, which is
why the two messages are English literals and why the codes are public. A page that needs translated
wording does not read the synthesized message:
ResultUiExtensions.LocalizedErrorMessagetranslates these two by their code, first through the localizer the page passes and then, when that has no such key, through the framework's ownSharedResourcepair for the current UI culture, and a page can also branch on the code or override the translation with the same key in its own resource pair. Keeping the type static and dependency-free is what lets services outside this package's hierarchy, including a consumer app's hand-written client, adopt the same contract with one call. - Where it's used: it wraps every dispatch in this package,
EntityServiceBase<TEntityDTO, TIdentifierType>
(
MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/EntityServiceBase.cs:359,389), ChildEntityServiceBase (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/ChildEntityServiceBase.cs:37,53,71), AuthUIService (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/AuthUIService.cs:214,227,248,263,276,299) and NotificationInboxService (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/NotificationInboxService.cs:46,64,84,101). Outside the framework it is called directly by services that sit outside the base hierarchy: Store's cart and lookup services (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.UI/Services/ShoppingCarts/CartStateService.cs:93,140,174,206,260,297,MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.UI/Services/Lookups/CustomerLookupService.cs:27,73,MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.UI/Services/Lookups/ProductVariantLookupService.cs:29,94) and Helpdesk's single API client (MMCA.Helpdesk/Source/Hosts/UI/MMCA.Helpdesk.UI.Web/Services/HelpdeskApiClient.cs:30,50,64,84,95,109,122,138,149). Its own behavior is pinned by HttpResultExecutorTests, including the code literals (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Api/HttpResultExecutorTests.cs:29-30), the timeout-versus-cancellation split (:150,164,178) and the rethrow paths (:192,206,219,230,240). - Caveats: a
JsonExceptionis classified as a transport fault, so a server that answers 200 with a body the client cannot deserialize produces the same generic "check the connection" message as a refused socket; the distinction is available in the error'sSourcebut not in its code. The two user-facing messages are hard-coded English at the source; they reach a localized UI only when the page renders them throughLocalizedErrorMessage, which replaces the sentence only while the error still carries the executor's own text (ResultUiExtensions.cs:398-405), so a page that displaysError.Messagedirectly still shows English.
PagedReadAll
MMCA.Common.UI ·
MMCA.Common.UI.Services.Api·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/PagedReadAll.cs:19· Level 3 · class (static)
- What it is: a helper that reads every row of an entity controller's
/pagedendpoint by requesting page after page until the server's reported total is reached, for hand-written services that need the whole set in one list (class summary, lines 8-11). - Depends on: Result and
Result<T>for the return shape, PagedCollectionResult<T> and its PaginationMetadata for each page (imported throughMMCA.Common.Shared.AbstractionsandMMCA.Common.UI.Common, lines 3-4). Externals: BCLCultureInfo,string.Create,System.Diagnostics.CodeAnalysis. - Concept introduced, a single read truncates silently.
[Rubric §19, State Management & Data Flow]assesses whether the data a page holds is the data the server has. The remarks (lines 12-18) record the trap: the API clamps any requested page size to its own maximum (ApplicationSettings.MaxPageSize, 500 by default), and the GET-all endpoint ignorespageSizeand always returns that maximum, so rows past it vanish with nothing to say so. Paging untilPaginationMetadata.TotalItemCountis reached is the only unbounded route.[Rubric §29, Resilience, Reliability & Business Continuity]applies to the loop bound: a server that misreports its total cannot make the client spin forever. - Walkthrough
PageSize(line 22) is a publicconst intof 500, the API's default maximum, so no request is clamped (comment, line 21).MaxPages(line 28) is a privateconstof 40, the runaway guard of 20,000 rows (comment, lines 24-27).LookupPageUrl(string entity, int pageNumber)(line 43) builds{entity}/paged?pageNumber=...&pageSize=500&sortColumn=Id&sortDirection=asc&includeFKs=false&includeChildren=falsewithstring.Create(CultureInfo.InvariantCulture, ...)(lines 44-46). Sorting byIdascending keeps consecutive pages from skipping or repeating a row, and the doc comment (lines 30-38) makes the exact text part of a contract: a host that warms its output cache by replaying these URLs must replay them byte for byte. TheCA1055suppression (lines 39-42) keeps the return a string for that reason, since callers both compose it as text and wrap it as a relativeUri.ReadAllAsync<T>(Func<int, Task<Result<PagedCollectionResult<T>>>> fetchPage, CancellationToken)(line 57) null-guards the delegate (line 61) and loopspageNumberfrom 1 toMaxPages(line 65). Each iteration checks cancellation first (line 67), fetches the page (line 69), and returns the first failure unchanged asResult.Failure<List<T>>(result.Errors)(lines 70-73). An empty page ends the loop (lines 75-79); otherwise the items are accumulated (line 81) and the loop ends once the count reachesTotalItemCount(lines 83-86). The method returnsResult.Success(accumulated)(line 89), so hitting the 40-page guard yields the rows read so far rather than a failure.
- Why it's built this way: the fetch is a delegate rather than an
HttpClient, so each caller keeps its own transport choice (an authenticatedSendRequestAsync, or an anonymous IdempotentReadRetry GET) and its own filters, while the paging rule lives once. Returning aResultrather than throwing keeps it inside the framework's client contract that a page branches on failures instead of catching them. - Where it's used: ADC UI services
SpeakerLookupService(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Speakers/SpeakerLookupService.cs:91),EventLookupService(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Events/EventLookupService.cs:97),CategoryItemLookupService(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Categories/CategoryItemLookupService.cs:37) andOrganizerFeedbackService(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Feedback/OrganizerFeedbackService.cs:27,82). The ADC Conference service'sSelfHttpOutputCacheWarmupTaskreplays URLs in theLookupPageUrlshape (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/SelfHttpOutputCacheWarmupTask.cs:38). Pinned byPagedReadAllTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Api/PagedReadAllTests.cs).OrganizerFeedbackServicecomposes its own filtered/pagedURLs withPagedReadAll.PageSizerather thanLookupPageUrl(OrganizerFeedbackService.cs:32,87), since it needs anEventIdorSessionIdfilter.
IRoleAdminUIService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Administration·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Administration/IRoleAdminUIService.cs:22· Level 3 · interface
- What it is: the typed client contract for the role-admin editor: read every role's compiled and stored permissions, read one role, read the catalog an editor renders from, and replace one role's stored permission set.
- Depends on: first-party: RolePermissionsResponse (the
per-role read and write shape), PermissionCatalogResponse
(the closed sets a role editor may choose from), and Result
/
Result<T>(IRoleAdminUIService.cs:30,36,44,54) for every member's return type. Its sole implementation is RoleAdminService. - Concept: each member's XML doc names the exact endpoint it calls (
GET Admin/Roles,GET Admin/Roles/{role},GET Admin/Roles/catalog,PUT Admin/Roles/{role}/permissions, lines 27,32,40,47-48), so the contract doubles as the wire map for the role-admin surface without a separate reference.[Rubric §1, SOLID Principles]reads this as a small, role-scoped interface: it names only what a role editor needs and nothing a user editor would, the mirror decision to the split between IUserAdminActionsUIService and IUserAdminUIService<TUserDto> one member table below. - Walkthrough:
GetAllAsync(line 30) andGetAsync(string role, ...)(line 36) are the two reads that feed the role list and role-detail pages.GetCatalogAsync(line 44) reads the roles-plus- permissions catalog a role editor renders its checkboxes from.SetStoredPermissionsAsync(role, permissions, ...)(lines 54-57) replaces the complete stored permission set for one role in a single PUT, not an incremental add/remove, so the caller always sends the full desired set. - Why it's built this way: this is an ADR-116 opt-in surface (ADR-116): the admin app can wire role management by registering RoleAdminService against this contract without the framework hard-wiring an admin UI into every consumer.
- Where it's used: implemented by RoleAdminService and registered against it in
DependencyInjection (
MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs); consumed byRoleAdminEdit.razor.csandRoleAdminList.razor.cs; its ADC identity pages carry the behavior-pinning tests (MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.UI.Tests/Pages/Roles/RoleEditTests.cs,RoleListTests.cs).
IUserAdminActionsUIService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Administration·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Administration/IUserAdminActionsUIService.cs:16· Level 3 · interface
- What it is: the account-mutation contract for user admin: lock, unlock, and two shapes of
role-replacement, a full-set
SetRolesAsyncand a single-role convenienceSetRoleAsync. - Depends on: first-party: SetUserRolesRequest (implied wire
shape behind
SetRolesAsync), Result (IUserAdminActionsUIService.cs:22,26,33,41,56,58);UserIdentifierType(the module's identifier alias) names every account parameter. Extended by IUserAdminUIService<TUserDto>. - Concept, a member that exists only because a mock cannot proxy a default interface method. The
remarks on
SetRoleAsync(lines 108-111) give the reasoning directly: the obvious shape would have been a default interface implementation overSetRolesAsyncthat wraps the single role in a one-element list, but a default implementation on an interface is not virtual to a mock proxy, so a test could neither stub nor verify it if it were written that way. DeclaringSetRoleAsyncas its own interface member keeps it mockable at the cost of one more line on the contract.[Rubric §14, Testability]is the rubric this decision serves. - Walkthrough:
LockAsync(userId, ...)(line 84) andUnlockAsync(userId, ...)(line 90) are the two session-revoking actions.SetRolesAsync(userId, roles, ...)(lines 97-100) replaces the complete role set an account holds.SetRoleAsync(userId, role, ...)(line 116) is documented as sending a one-element set to the same replacement endpoint (lines 102-106), the shape both shipped consumers need because each holds exactly one role per account. - Why it's built this way: separated from the read/page surface in IUserAdminUIService<TUserDto> so the mutating actions have one narrow, generic-free contract a page or a test can depend on without also depending on the DTO type parameter.
- Where it's used: extended by IUserAdminUIService<TUserDto>;
implemented by UserAdminService<TUserDto>; called directly from
UserAdminList.razor.csfor the lock/unlock/role actions on the user-admin grid, and pinned byUserListTests.cs(ADC) andUserAdminListTests.cs(Common).
ChangePasswordCard
MMCA.Common.UI ·
MMCA.Common.UI.Components.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/Auth/ChangePasswordCard.razor.cs:18· Level 3 · class (partial component)
- What it is: the shared change-password form for a signed-in user: three password fields inside a
MudForm, client-side length and confirmation checks, and a submit that calls the auth service and reports the outcome through toasts. A consumer app drops it into its profile page instead of re-implementing the form (ADC does so with a bare<ChangePasswordCard />,MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/Users/Profile/Profile.razor:85). - Depends on: IAuthUIService (
@inject AuthService,ChangePasswordCard.razor:2), IToastService (ChangePasswordCard.razor:3),IStringLocalizer<ChangePasswordCard>(ChangePasswordCard.razor:4) with the plural helper StringLocalizerPluralExtensions, ResultUiExtensions forLocalizedErrorMessage(ChangePasswordCard.razor.cs:130), and ComponentLifetimeExtensions forLifetimeToken()(ChangePasswordCard.razor.cs:114). Externals:MudFormandMudTextFieldfrom MudBlazor. - Concept introduced, a form that reports the server's reason, not its own guess.
[Rubric §24, Forms, Validation & UX Safety]assesses whether validation feedback is honest and where it is raised. Two layers are visible here. The client checks only what it can know cheaply: the new password meetsMinLength(ChangePasswordCard.razor.cs:88-91) and the confirmation matches (ChangePasswordCard.razor.cs:93-96); the server still enforces its own rules (doc comment,ChangePasswordCard.razor.cs:33-36). A server refusal is shown with the server's own message when it has one, falling back to a fixedChangePassword.Failedtext only when the failure carries none (commentChangePasswordCard.razor.cs:127-130). - Walkthrough
- Parameters:
MinLengthdefaults to8, the framework's password policy minimum, and also feeds the helper text (ChangePasswordCard.razor.cs:38;ChangePasswordCard.razor:18).MaxLengthis an optionalint?wherenullleaves the field unbounded (ChangePasswordCard.razor.cs:45); the razor falls back to the privateNoMaxLengthconstant524288, MudTextField's own default (ChangePasswordCard.razor.cs:20-21;ChangePasswordCard.razor:20).AdditionalActions(RenderFragment?) renders extra actions, such as a device-management link, after the submit button (ChangePasswordCard.razor.cs:52;ChangePasswordCard.razor:38). - Callbacks:
OnChangedfires after success, once the toast has been shown (ChangePasswordCard.razor.cs:56);OnFailedcarries the failedResultafter the failure toast, and is not raised for a client-side validation failure, which the form itself reports (ChangePasswordCard.razor.cs:63). - Validators:
ValidateNewPasswordandValidateConfirmPasswordreturnnullfor valid or empty input and leave theRequiredcheck to report a missing value (commentChangePasswordCard.razor.cs:86-87). The length message is plural-aware throughL.Plural(ChangePasswordCard.razor.cs:91), the mismatch usesValidation.PasswordsDoNotMatch(ChangePasswordCard.razor.cs:96), and the confirmation comparison is ordinal (ChangePasswordCard.razor.cs:94). SavePasswordAsync(ChangePasswordCard.razor.cs:98) returns when the@ref-set form is absent (:100-103), validates the form and stops on invalid (:105-109), then sets_isSavingand callsAuthService.ChangePasswordAsync(current, new, _cts.LifetimeToken())(:114). On success it resets the form, clears the three fields, toastsChangePassword.Successand raisesOnChanged(:116-124). On failure it toasts and raisesOnFailed(result)(:130-131). A cancelled call is swallowed (:134-137), and_isSavingalways clears infinally(:140).- Disposal: the component implements
IDisposable(ChangePasswordCard.razor:5).Dispose(bool)is idempotent, sets_disposed, then cancels and disposes theCancellationTokenSource(ChangePasswordCard.razor.cs:74-83). TheMudFormreference is not disposed here because the renderer owns it (commentChangePasswordCard.razor.cs:25).
- Parameters:
- Why it's built this way: the cancel-on-dispose token is what makes a navigation away, or an
InteractiveAuto render-mode transition, harmless: the in-flight request is cancelled and the
OperationCanceledExceptionis expected (commentChangePasswordCard.razor.cs:136). TakingMinLengthandMaxLengthas parameters lets a consumer tighten the form without forking it, while the server remains the authority. Localization follows ADR-027 (Website/docs-src/adr/027-multi-locale-i18n.md). - Where it's used: ADC's profile page renders it (
Profile.razor:85). Its behavior is pinned byChangePasswordCardTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Components/Auth/ChangePasswordCardTests.cs), andFormsConventionTestsBase(MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/Ui/FormsConventionTestsBase.cs) references it.
ILegalAcceptanceUIService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Legal·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Legal/ILegalAcceptanceUIService.cs:16· Level 3 · interface
- What it is: the UI-side contract for Terms of Service acceptance: read the signed-in user's
standing, and record an acceptance of the version the user was shown. It is the dependency behind
TermsAcceptanceGate (
TermsAcceptanceGate.razor.cs:61). - Depends on:
Result<T>(ILegalAcceptanceUIService.cs:1) andLegalAcceptanceDTO(MMCA.Common.Shared.Legal,ILegalAcceptanceUIService.cs:2); both calls targetLegalAcceptanceRoutes.Path(ILegalAcceptanceUIService.cs:11). - Concept introduced, no failure should block the user.
[Rubric §9, API & Contract Design]assesses whether the client treats the API's error channel as a typed contract. The remarks say every member returns aResultcarrying the API's own errors, including a 404 for a host that serves no such endpoint, and nothing throws for a server answer or a transport fault, so a caller can treat any failure as "do not block" (ILegalAcceptanceUIService.cs:10-15). - Walkthrough:
GetAsync(cancellationToken)reads the standing viaGET Users/me/legal-acceptance(:21).AcceptAsync(version, cancellationToken)records an acceptance viaPOST Users/me/legal-acceptanceand returns the standing after acceptance (:27). - Why it's built this way: an interface keeps the gate component testable and lets a host swap the
transport. The framework registers the default with
TryAddScoped(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:170), so a consumer's own registration wins. - Where it's used: implemented by LegalAcceptanceUIService; consumed
by TermsAcceptanceGate and by ADC's
LegalAndDataCard(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/Users/Profile/LegalAndDataCard.razor.cs).
ChildEntityServiceBase
MMCA.Common.UI ·
MMCA.Common.UI.Services.Api·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/ChildEntityServiceBase.cs:19· Level 4 · class (abstract)
- What it is: the two-verb service base for join entities, the many-to-many rows a UI can create
and delete but never lists or edits on their own. It offers
PostAsync(in two shapes) andDeleteByIdAsyncover the named"APIClient", and nothing else. - Depends on: AuthenticatedServiceBase (base class, supplying the
authenticated client factory,
ChildEntityServiceBase.cs:22), ITokenStorageService (constructor parameter, passed straight through, line 21), HttpResultExecutor (lines 37, 53, 71), ProblemDetailsResultReader (lines 42, 58, 77), Result and ErrorType (line 2);IHttpClientFactoryandSystem.Net.Http.Json(BCL). - Concept introduced, a base class shaped by the resource rather than by convention.
[Rubric §18, UI Architecture & Component Design]assesses whether the presentation layer talks to the backend through typed services rather than rawHttpClientcalls in components. The interesting design choice here is what is absent: a join row likeSessionSpeakerhas no list page, no edit form and no lookup, so this base deliberately does not implement IEntityService<TEntityDTO, TIdentifierType>. Giving join services the full CRUD surface would hand pages six operations of which four have no endpoint behind them.[Rubric §1, SOLID Principles]reads this as interface segregation applied at the service-base level: the smaller base cannot promise what the API does not serve. - Walkthrough
- The primary constructor takes
IHttpClientFactory,ITokenStorageServiceand astring endpoint, forwarding the first two toAuthenticatedServiceBase(lines 19-22). The endpoint is captured as a primary-constructor parameter rather than exposed as a property, so subclasses cannot rewrite it; contrast EntityServiceBase<TEntityDTO, TIdentifierType>, which surfacesprotected string Endpoint { get; }because its own methods build sub-paths from it. PostAsync<TResponse>(object request, CancellationToken)(line 36) is for an endpoint that answers with the created DTO. Inside aHttpResultExecutor.ExecuteAsyncwrapper (line 37) it creates an authenticated client (line 40), POSTs the payload as JSON to the relative endpoint (line 41), and hands the response toProblemDetailsResultReader.ReadAsync<TResponse>(line 42). Therequestparameter is typedobjecton purpose, and the doc comment explains why (lines 28-33): join payloads are anonymous objects,System.Text.Jsonserializes the runtime type for anobjectdeclaration, and a generic request parameter would force a caller to name a type it cannot spell.PostAsync(object request, CancellationToken)(line 52) is the same call for an endpoint that answers 204, returning a non-genericResultthrough the reader's body-less overload (line 58). The two overloads exist because the reader treats a missing body as a failure on the generic path.DeleteByIdAsync(string id, CancellationToken)(line 70) builds"{endpoint}/{id}"(line 75) and DELETEs it (line 76). A join row that is not there answers 404, which arrives as anErrorType.NotFoundfailure rather than a barefalse, so a caller can still separate "nothing to remove" from "the remove failed" (documented at lines 62-66).- The id parameter is a
string, not a typed identifier: subclasses format their own key before calling. ADC's four join services route it through one helper that also appends the parent key as a query parameter,ChildEntityDeletePath.For(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Common/ChildEntityServices.cs:76-87), which is why the whole difference between a removal that works and one the API answers 404 to sits in a single place.
- The primary constructor takes
- Why it's built this way: join endpoints sit behind
[Authorize]exactly like their parent CRUD endpoints, so they need the same bearer plumbing and the same error contract, but none of the paging, filtering or lookup machinery. Deriving from AuthenticatedServiceBase rather than from EntityServiceBase<TEntityDTO, TIdentifierType> reuses the auth path while keeping the surface honest. Two deliberate asymmetries with that sibling are recorded in ADR-094 (Website/docs-src/adr/094-client-entity-data-access.md:95-106): these calls run outsideRetryPolicy, so a join add or remove is single-attempt, andPostAsyncsends noIdempotency-Key, so a duplicate join is stopped by the domain invariant and the unique index behind it rather than by request deduplication (ADR-017,Website/docs-src/adr/017-request-idempotency.md). - Where it's used: four ADC Conference join services derive from it, all in one file,
EventSpeakerService on
eventspeakers, SessionSpeakerService onsessionspeakers, SessionCategoryItemService onsessioncategoryitemsand SpeakerCategoryItemService onspeakercategoryitems(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Common/ChildEntityServices.cs:22,35,48,61). Each adds a typedAddAsync/DeleteAsyncpair over the two protected methods and implements its own module interface (for example:25-29). The base is pinned by ChildEntityServiceBaseTests. - Caveats: because there is no retry, a transient 503 on a join add surfaces to the user as a
failure that the equivalent CRUD call would have retried away; that is a decision, not an oversight,
but it is invisible from the subclass. Neither
PostAsyncoverload exposes the response headers, so an endpoint answering201 Createdwith aLocationheader gives the caller no way to read it.
EntityServiceBase<TEntityDTO, TIdentifierType>
MMCA.Common.UI ·
MMCA.Common.UI.Services.Api·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/EntityServiceBase.cs:43· Level 4 · class (abstract)
- What it is: the CRUD workhorse of the UI layer. It implements IEntityService<TEntityDTO, TIdentifierType> against a REST endpoint by turning each operation into a URL plus a one-line HTTP lambda, and funnels every one of them through two dispatch methods that own retry, idempotency, conditional writes, error translation and deserialization. An optional read cache sits in front of the four reads.
- Depends on: AuthenticatedServiceBase (base class, line 47),
IEntityService<TEntityDTO, TIdentifierType> (implemented
interface, line 47),
IBaseDTO<TIdentifierType> (the
TEntityDTOconstraint, line 48), BaseLookup<TIdentifierType> (line 132), CollectionResult<T> and PagedCollectionResult<T> with its PaginationMetadata (lines 80, 128, 137), IUiReadCache (optional constructor parameter, line 47), IConcurrencyAware and ConcurrencyETag (lines 210-212, 424), IdempotencyHeaders (line 413), ProblemDetailsResultReader (lines 366, 394), HttpResultExecutor (lines 359, 389) and ITokenStorageService (line 46); Polly through the inheritedRetryPolicy, andSystem.Net.Http.Json(BCL). - Concept introduced, one dispatch point for every cross-cutting HTTP concern.
[Rubric §29, Resilience, Reliability & Business Continuity]assesses whether retry, auth and error handling are applied in one place instead of repeated per call: the six public methods contain only URL construction, and the twoSendRequestAsyncoverloads (lines 351 and 381) contain all of the policy.[Rubric §19, State Management & Data Flow]applies because components never touchHttpClient: they inject the typed interface and receive DTOs wrapped in aResult.[Rubric §29, Resilience, Reliability & Business Continuity]applies through the inherited three-retry exponential-backoff-with-jitter policy (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/AuthenticatedServiceBase.cs:27), whose predicate retries 5xx plus 408 and 429 but not 501 or 505 (AuthenticatedServiceBase.cs:108). - Concept introduced, retry safety for a non-idempotent verb.
[Rubric §9, API & Contract Design]assesses whether client and server share an explicit protocol for duplicate writes. A retry policy that re-issues a POST is a correctness hazard: if the first attempt reached the server and only the response was lost, the retry creates a second record.AddAsyncis the one method that passes a key (line 174), minted byAuthenticatedServiceBase.NewIdempotencyKey()as a compact GUID (AuthenticatedServiceBase.cs:51). The key is set as a default request header on the client (line 413) rather than per request, and that one client instance serves every retry attempt, so all attempts carry the identical value (the comment at lines 409-412 says exactly that). The retry predicate enforces the same rule from the other side: a POST or PATCH response is retried only when its request carried a non-blank key (AuthenticatedServiceBase.IsReplaySafe,AuthenticatedServiceBase.cs:134), so a keyless write is sent once. The server side is the opt-in IdempotencyFilter, and both ends read the header name from the shared IdempotencyHeaders constant. Reads, full-PUT updates and deletes send no key because they are naturally idempotent (comment at lines 168-170). - Concept introduced, the conditional write.
[Rubric §24, Forms, Validation & UX Safety]assesses whether the UI protects a user from silently overwriting somebody else's work.UpdateAsyncsends the DTO's concurrency token as anIf-Matchentity tag (line 196), and that header is the only route the token travels (remarks at lines 182-187). A DTO carrying no token sends no header, and the server answers428 Precondition Requiredrather than accepting a blind write.ConcurrencyTagOf(line- is the whole rule: a DTO that implements
IConcurrencyAware with a non-empty
RowVersionis formatted byConcurrencyETag.Format, anything else yieldsnull(lines 210-212). This is ADR-035 (Website/docs-src/adr/035-optimistic-concurrency.md) seen from the client.
- is the whole rule: a DTO that implements
IConcurrencyAware with a non-empty
- Concept introduced, a read-through cache keyed by the request URL.
[Rubric §23, Front-End Performance & Rendering]assesses whether the client avoids work it has already done.GetCachedAsync(line 253) is the client half of ADR-040 (Website/docs-src/adr/040-authenticated-output-caching-for-public-reads.md): the relative URL, path plus full query string, is the cache key, deliberately matching the server-side output cache'sQueryKeys = "*"shape, and theCA1054suppression at lines 249-252 exists to keep it a verbatim string rather than a re-encodedSystem.Uri. - Walkthrough
- The primary constructor takes
endpoint,IHttpClientFactory,ITokenStorageServiceand an optional IUiReadCache (lines 43-47); note the parameter order differs from ChildEntityServiceBase. Both type parameters are constrained,TEntityDTO : IBaseDTO<TIdentifierType>andTIdentifierType : notnull(lines 48-49).Endpointis republished as a protected property (line 51) because the read methods append sub-paths to it, andReadCachelikewise (line 58) so a derived service can invalidate a prefix its own custom write touched. With no cache registered, every read goes to the API and the class behaves exactly as it did before the cache existed (constructor docs, lines 37-42). PagedIncludeFKs(line 65) is aprotected virtual boolthat defaults tofalse, so the paged URL is unchanged by default; a service whose list grid shows a foreign-key name (a product's category) overrides it totrue, andGetPagedAsyncthen appendsincludeFKs=True(lines 104-107). Only the paged read has this hook:GetAllAsynctakesincludeFKsas an argument (line 69).GetAllAsync(includeFKs, includeChildren, ct)(line 68) builds a two-parameter query string (lines 73-77), goes through the cache (line 80), and maps the paged envelope down to its items (line 82). The "all" endpoint answers with the paged envelope, not a bare array.GetPagedAsync(filters, pageNumber, pageSize, sortColumn, sortDirection, includeChildren, ct)(line 86) is the one with real work. Page numbers are formatted withstring.Create(CultureInfo.InvariantCulture, ...)(lines 97-98) so a comma-decimal locale cannot corrupt the query, and every filter property, operator and value goes throughUri.EscapeDataString(lines 115-117). Filters serialize asfilters[Property].operator=plus an optionalfilters[Property].value=, and a filter whose operator is blank is skipped entirely (line 113), which is how a grid clears a column filter. It targets{Endpoint}/paged(line 122) and maps the envelope to the(Items, TotalItems)tuple a server-side data grid binds to (lines 127-128).GetAllForLookupAsync(nameProperty, ct)(line 132) hits{Endpoint}/lookup(line 136) and maps aCollectionResult<BaseLookup<TIdentifierType>>to its items (lines 137-139), the lightweight id-plus-name shape that feeds dropdowns and autocompletes.GetByIdAsync(id, includeChildren, ct)(line 143) is a plain cached GET (line 158). A missing entity is aNotFoundfailure, not a null, and the comment records both halves of why (lines 155-157): the caller can tell it apart from a transport failure via ResultUiExtensions.IsNotFound, and a failure is never cached, so a 404 is re-asked every time.AddAsync(entity, ct)(line 162) POSTs with the idempotency key (lines 171-175) and then callsInvalidateAfterWrite(line 177).UpdateAsync(entity, ct)(line 188) PUTs to{Endpoint}/{GetEntityId(entity)}with theIf-Matchtag (lines 192-197);DeleteAsync(id, ct)(line 215) DELETEs{Endpoint}/{id}(lines 219-223). All three callInvalidateAfterWrite(lines 177, 199, 225), which invalidates on success and on most failures (see below).GetEntityId(entity)(line 229) isprotected virtualand returnsentity.Id, the hook a subclass overrides when the route key is not the DTO's own id.GetCachedAsync<T>(url, ct, bypassCache)(line 253) guards the url (line 258), goes straight to the network when no cache is registered or the caller asked to bypass (lines 260-263), answers from a fresh entry when there is one (lines 265-268), and otherwise fetches and stores. Before the GET it capturesReadCache.Generation(line 273), and the store passes that value back asReadCache.Set(url, result.Value, generation)(line 280); the comment (lines 270-272) explains that a write invalidating this endpoint while the read is in flight moves the generation, so the late store is dropped instead of re-caching a value the write just made stale. Only a success with a non-null value is stored (lines 276-281), because caching a failure would pin a transient outage in front of the user for the whole TTL and a cached 404 would survive the create that fixed it.bypassCacheexists for a read the user explicitly asked to be current, a refresh button or a re-poll after a push (documented at lines 243-247).InvalidateAfterWrite(line 300) drops this endpoint's cached reads by prefix, and it is no longer success-only. It invalidates when the write succeeded or when any error is NOT one the server raised before touching state (line 302).IsRejectedBeforeWrite(line 309) lists the refused-before-write types:Validation,UnprocessableEntity,Unauthorized,ForbiddenandTooManyRequests(lines 310-314). Those changed nothing, so invalidating would throw away entries that are still accurate. Every other outcome invalidates because server state is changed or unknown (doc, lines 291-298): a 412, 404 or 409 can answer the retry of a write whose first attempt landed and only lost its response, and a 5xx or transport failure says nothing about whether it landed.AsReadOnlyList(line 320) presents a deserializedItemscollection without assuming the JSON reader produced a list (lines 321-326).SendRequestAsync<T>(line 351) andSendRequestAsync(line 381) are the center of the class and are structurally identical: null-guard the lambda (lines 357, 387), wrap everything in HttpResultExecutor (lines 359, 389), build a client for this one logical operation (lines 364, 392), execute the caller's lambda throughRetryPolicywith the cancellation token threaded in so a cancelled operation does not sleep out its backoff (lines 365, 393), and hand the response to ProblemDetailsResultReader (lines 366, 394). The generic overload fails a 2xx with no body via the reader'sEmptyResponseCode, which is why the body-less overload exists at all (documented at lines 346-350). The client is kept in scope across the read on purpose (comment at lines 362-363).CreateRequestClientAsync(idempotencyKey, ifMatch)(line 403) is where both conditional headers are attached (lines 407-425), each with a comment stating the retry property it preserves: the same key on every attempt, and the same precondition on every attempt so a write that lost the race fails consistently instead of succeeding on a later attempt against a version the caller never saw. TheIf-Matchcomment (lines 418-423) adds the other half: when the first attempt landed and only its response was lost, the retry answers 412 (or 404 for a soft-deleted row) for a write that happened, which is why that outcome still invalidates the cache throughInvalidateAfterWrite.
- The primary constructor takes
- Why it's built this way: passing the HTTP call as a
Func<HttpClient, Task<HttpResponseMessage>>lets each verb stay a two-line method while every policy decision lives once. The composition inside the dispatch is the load-bearing part, and ADR-094 records it as the client contract (Website/docs-src/adr/094-client-entity-data-access.md:60-94): the executor outside, the retry policy in the middle, the reader innermost. Nothing here throws for a server answer, so a 404, a validation rejection and a 500 are all failures a page can branch on; the only exception that still escapes is the caller's own cancellation (class comment, lines 25-30). All six public methods arevirtual, so a module service overrides only the one that needs domain-specific behavior and inherits the rest. - Where it's used: it is the base of essentially every module CRUD service. ADR-094's inventory as
of 2026-08-31 counts sixteen production subclasses: nine in ADC Conference including
EventService and
SessionService, six in Store (
ProductService,CategoryService,OrderService,ShoppingCartService,InventoryItemService,CustomerService), and one inside the framework itself, PushNotificationService (Website/docs-src/adr/094-client-entity-data-access.md:107-118). ADC Identity's UserService takes the auth root directly instead. The consumer on the page side is DataGridListPageBase<TDto>, which is handed aGetPagedAsynccall as its fetch delegate. Behavior is pinned by EntityServiceBaseTests, EntityServiceBaseCachingTests and EntityServiceBaseIdempotencyRetryTests, which asserts the key is emitted on creates only and stays identical across attempts. - Caveats:
GetAllAsyncsends no page size, but it is not unbounded: the "all" endpoint ignorespageSizeand returns at most the API's maximum page size (500 by default), so rows past it are silently dropped (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/PagedReadAll.cs:12-18). A service that needs every row pages with PagedReadAll, and grids useGetPagedAsync. The generation guard only works against an IUiReadCache that implements it: the interface defaultsGenerationto0and the three-argumentSetto the plainSet(MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Caching/IUiReadCache.cs:40,70), which the shipped UiReadCache overrides (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Caching/UiReadCache.cs:34,104). The read cache is keyed by URL alone and holds nothing about who fetched it, so it is only safe while IUiReadCache stays a per-circuit registration.GetPagedAsyncaccepts afiltersdictionary that the signature does not declare nullable (line 87), yet the body null-checks it (line 109): defensive against a caller the signature says cannot exist.
LegalAcceptanceUIService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Legal·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Legal/LegalAcceptanceUIService.cs:23· Level 4 · class (sealed)
- What it is: the default ILegalAcceptanceUIService: a small HTTP service that reads and records the signed-in user's Terms acceptance against the framework's legal-acceptance route.
- Depends on: AuthenticatedServiceBase (base class,
:26) for the bearer-token client andRetryPolicy, ITokenStorageService (constructor,:24-25), HttpResultExecutor and ProblemDetailsResultReader for the error contract, plusLegalAcceptanceRoutes.Path,LegalAcceptanceDTOandAcceptLegalTermsRequestfrom the Shared project. - Concept introduced, the same dispatch recipe, minus the generic base.
[Rubric §29, Resilience, Reliability & Business Continuity]applies through the shared pipeline shape ADR-094 records (Website/docs-src/adr/094-client-entity-data-access.md): executor outside, reader innermost. The class does not derive from EntityServiceBase<TEntityDTO, TIdentifierType> because the route is not CRUD, so each method composes the pieces itself. - Walkthrough
GetAsync(:29) wraps the call inHttpResultExecutor.ExecuteAsync(:30), creates the authenticated client (:33), sends the GET throughRetryPolicy.ExecuteAsync(:34) and reads the response withProblemDetailsResultReader.ReadAsync<LegalAcceptanceDTO>(:38).AcceptAsync(:44) rejects a blankversion(:46), then wraps the call the same way (:48) but posts anAcceptLegalTermsRequest(version)straight through the client (:52-55) withoutRetryPolicy, so the write is sent once. This fits the replay rule: the retry predicate would not retry a keyless POST anyway (AuthenticatedServiceBase.cs:115-118).
- Why it's built this way: keeping it
sealedand thin leaves the gate component with one mockable interface, and the executor guarantees the "any failure means do not block" contract the interface documents. - Where it's used: registered as the scoped default for the interface
(
MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:170) and exercised byLegalAcceptanceUIServiceTests(MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Services/Legal/LegalAcceptanceUIServiceTests.cs).
IUserAdminUIService<TUserDto>
MMCA.Common.UI ·
MMCA.Common.UI.Services.Administration·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Administration/IUserAdminUIService.cs:18· Level 4 · interface
- What it is: the read-plus-actions contract a user-admin page depends on: the two paged/single reads generic over the consumer's own user DTO, plus every mutation from IUserAdminActionsUIService via interface inheritance.
- Depends on: extends IUserAdminActionsUIService (line 18); its
reads are generic over
TUserDto, the consumer-supplied user row shape.UserIdentifierTypenames the account parameter ofGetAsync. Implemented by UserAdminService<TUserDto>. - Concept, the type parameter is the seam between a shared service and an app-specific DTO. ADC and
Store each ship their own user row shape (different columns for the admin grid), so the interface is
generic rather than fixed to one DTO, and only the read half needs the parameter: the write half in
IUserAdminActionsUIService takes only an id and never returns a DTO, so
it carries no type parameter of its own.
[Rubric §1, SOLID Principles]reads the split as interface segregation again: a caller that only needs to lock or re-role an account depends on the narrower, non-generic interface. - Walkthrough:
GetPagedAsync(pageNumber, pageSize, searchTerm, role, ...)(lines 146-151) reads one page of accounts fromGET Admin/Users/paged, with an optional free-text search and an optional role filter; the doc (line 142) leaves which fields the search covers to the server.GetAsync(userId, ...)(line 157) reads one account, including its lock state, fromGET Admin/Users/{userId}. - Why it's built this way: generic over
TUserDtorather than one fixed shape keeps the paging and single-read plumbing in the framework while each consumer app supplies its own admin row DTO. - Where it's used: implemented by UserAdminService<TUserDto>,
registered against it by DependencyInjection; consumed by
UserAdminList.razor.csand ADC'sUserDetail.razor.cs(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/Users/UserDetail/UserDetail.razor.cs), and pinned byUserAdminListTests.cs(Common) andUserDetailTests.cs(ADC).
RoleAdminService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Administration·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Administration/RoleAdminService.cs:25· Level 4 · class (sealed)
- What it is: the HTTP implementation of IRoleAdminUIService, the four calls
against the
Admin/Rolesendpoint family. - Depends on: AuthenticatedServiceBase (base class,
RoleAdminService.cs:25), IRoleAdminUIService (implemented interface), HttpResultExecutor (lines 217, 243), ProblemDetailsResultReader (lines 228, 251), RolePermissionsResponse and PermissionCatalogResponse (the response bodies), SetRolePermissionsRequest (the write payload, line 224);IHttpClientFactoryand ITokenStorageService (primary-constructor parameters, lines 178-180). - Concept, one private generic GET carries all three reads.
GetAsync<TResponse>(url, cancellationToken)(lines 242-254) is the whole shared shape: authenticate, retry through the inheritedRetryPolicy, read the body or the Problem Details.GetAllAsync,GetAsync(role, ...)andGetCatalogAsyncare each a one-line call into it with a different URL and response type (lines 186-188, 191-200, 203-204). Only the write,SetStoredPermissionsAsync, needs its own method, because it is the one call whose response reader differs (aRolePermissionsResponserather than a bare success) and whose HTTP verb isPUT. - Walkthrough:
Endpoint(line 183) is the private constant"Admin/Roles".GetAllAsync(lines 186-188) hits the endpoint bare.GetAsync(string role, ...)(lines 191-200) null-checksrolethen appendsUri.EscapeDataString(role), so a role name with reserved URL characters still round-trips.GetCatalogAsync(lines 203-204) appends/catalog.SetStoredPermissionsAsync(lines 207-232) null-checks both arguments, builds{Endpoint}/{role}/permissions, and PUTs a SetRolePermissionsRequest wrapping the complete permission list (line 224), inside the same authenticate-retry-read wrapper as the private read helper but written out separately because it POSTs (in fact PUTs) rather than GETs. - Why it's built this way: matches the composition every other service in this package uses,
HttpResultExecutor outside,
RetryPolicyin the middle, ProblemDetailsResultReader innermost, so a reader who has studied EntityServiceBase<TEntityDTO, TIdentifierType> already knows the shape here. It is a plain AuthenticatedServiceBase subclass rather than an EntityServiceBase<TEntityDTO, TIdentifierType> one because roles are not a CRUD resource with create/delete: only read and one full-replace write exist. - Where it's used: registered against IRoleAdminUIService by
DependencyInjection
(
MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs). See ADR-116.
UserAdminService<TUserDto>
MMCA.Common.UI ·
MMCA.Common.UI.Services.Administration·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Administration/UserAdminService.cs:26· Level 5 · class (sealed)
- What it is: the HTTP implementation of
IUserAdminUIService<TUserDto>, generic over the consumer's user DTO,
covering paged read, single read, lock/unlock, and role replacement against the
Admin/Usersendpoint family. - Depends on: AuthenticatedServiceBase (base class),
IUserAdminUIService<TUserDto> (implemented interface),
HttpResultExecutor (lines 534, 558, 595, 618),
ProblemDetailsResultReader (lines 542, 566, 606, 631),
PagedCollectionResult<T> (line 542, the
paged-read response envelope), SetUserRolesRequest (line 602,
the role-replace payload);
UserIdentifierTypenames every account parameter;System.Globalization.CultureInfo.InvariantCultureformats every interpolated URL segment (lines 518-519, 556, 593, 616). - Concept, the same read/write composition as its sibling RoleAdminService, plus
one query-string builder.
GetPagedAsync(lines 509-549) is the one method with real assembly work: it always sendspageNumberandpageSize(lines 516-520), and appendssearchTermandroleonly when non-blank, each throughUri.EscapeDataString(lines 522-530), the same optional-filter pattern EntityServiceBase<TEntityDTO, TIdentifierType> uses for its own query strings. The paged envelope is mapped down to the(Items, TotalItems)tuple a server-side data grid binds to (lines 547-548). - Walkthrough:
Endpoint(line 506) is the private constant"Admin/Users".GetAsync(userId, ...)(lines 552-570) is a plain cached-free GET at{Endpoint}/{userId}.LockAsyncandUnlockAsync(lines 573-578) both delegate to the privatePostLockChangeAsync(userId, action, ...)(lines 611-634) with"lock"or"unlock"as the action segment.SetRoleAsync(userId, role, ...)(lines 581-585) wraps the single role in a one-element array and callsSetRolesAsync, the concrete implementation of the convenience member IUserAdminActionsUIService declares.SetRolesAsync(userId, roles, ...)(lines 588-609) PUTs SetUserRolesRequest to{Endpoint}/{userId}/roles.PostLockChangeAsync(lines 611-634) POSTs to{Endpoint}/{userId}/{action}with no retry: the comment (lines 623-625) states the reasoning directly, the endpoint is declared non-idempotent, so a retried POST is a second request rather than a replayed response, even though both lock and unlock are idempotent in the domain, because the client should not make that call on the endpoint's behalf. - Why it's built this way: matches the executor-outside/retry-middle/reader-innermost composition
every other service in this package uses, generic over
TUserDtoso ADC and Store each supply their own admin-grid row shape without a second copy of the HTTP plumbing. The deliberate absence of retry on the two POST actions is the one place this service diverges from the pattern, and it is a documented choice rather than an oversight. - Where it's used: registered against IUserAdminUIService<TUserDto>
by DependencyInjection
(
MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs); consumed by ADC'sUserDetail.razor.cs.
NotificationBell
MMCA.Common.UI ·
MMCA.Common.UI.Components.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/Notifications/NotificationBell.razor.cs:30· Level 6 · class (partial, component)
- What it is: the code-behind for the app-bar notification bell. It renders an unread badge from the scoped NotificationState, and exactly one instance at a time holds the single active-poller slot, so a bell placed in two layout slots never doubles the API traffic.
- Depends on: first-party: NotificationState (badge value, change and refresh events, staleness clock, and the poller slot), INotificationInboxUIService (the unread-count call), NotificationBellOptions (the two intervals), NotificationRoutePaths (the inbox route), ComponentLifetimeExtensions (the
LifetimeToken()read of the component's token source), and SharedResource (the resx anchor for the injected localizer). Externals:Microsoft.AspNetCore.Components([Inject],NavigationManager,LocationChangedEventArgs,OnAfterRenderAsync,InvokeAsync,StateHasChanged),Microsoft.Extensions.Options(IOptions<T>),Microsoft.Extensions.Localization(IStringLocalizer<T>), BCLTimeProvider,PeriodicTimer,CancellationTokenSource,IDisposable. - Concept introduced, a poller slot with symmetric registration and handover.
[Rubric §19, State Management & Data Flow]assesses how shared UI state is coordinated. The bell is a component, so a responsive layout can legitimately render it twice at once (desktop app bar and mobile drawer). Without coordination each copy would start its own timer and its own navigation refresh, doubling the unread-count endpoint's load for no user benefit. The design is a slot held by an owner object, not a bare counter:State.TryRegisterPoller(this)(line 55) takes the slot only when it is free or already this instance's (NotificationState.cs:111-124), andState.UnregisterPoller(this)releases it only when this instance actually holds it and then raisesOnPollerSlotFreed(NotificationState.cs:133-149).- Why owner identity matters here. The class remarks (lines 22-29) name the real scenario: hosts render the bell inside
<AuthorizeView>, which tears the children down and rebuilds them on every authentication-state change, including a routine access-token refresh. Registration is therefore strictly symmetric (every instance unregisters on dispose, whether or not it was polling, line 267), and the surviving instance claims the freed slot throughOnPollerSlotFreed(subscribed at line 52), so the circuit never ends up with a badge that nobody refreshes. [Rubric §23, Front-End Performance]assesses avoidable network work. Three mechanisms cut it. The slot removes an entire duplicate polling stream in dual-placement layouts, and it owns every refresh trigger, not just the timer: the push-refresh subscriptionState.OnRefreshRequested += HandleRefreshRequestedis made insideBecomeActivePollerAsync(line 80), next to the navigation hook, because subscribing every bell made each push read the API once per placement (comment, lines 77-79); a takeover runs that method again, so the surviving bell picks the subscription up with the slot. And the navigation trigger is throttled by staleness rather than firing per click:OnLocationChangedreads only whenState.IsStale(Options.Value.NavigationRefreshMaxAge)(line 166), because navigation is an ambient trigger, not evidence that the count moved (doc, lines 154-159). The defaults are 30 seconds for both the poll interval and the navigation max age (MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Settings/NotificationBellOptions.cs:23,30), and both are configuration, not constants. A zero or negativePollIntervalswitches the periodic backstop off entirely (lines 96-99) while push and navigation refresh keep working; the comment (lines 94-95) records thatPeriodicTimerrejects such a period and throwing there faulted the circuit.- The staleness policy has exactly three tiers, and they are deliberate. The first read and every periodic tick are unconditional; a navigation reads only past the configured window; a real-time push calls
State.MarkStale()first and then reads regardless (lines 182-186), because the server has just said the data changed, so the age of the number carries no information any more (doc, lines 177-179). That is the one path that must never be throttled. [Rubric §14, Testability]assesses whether time-dependent behavior can be driven deterministically. Both the timer and the age comparison run off the injectedTimeProvider Clock(line 37), and thePeriodicTimeris constructed with theTimeProvideroverload (line 103) precisely so a test drives the loop instead of waiting out a real interval (comment, lines 101-102).[Rubric §21, Accessibility]: the bell button carries an explicit localizedaria-label="@L["Notif.Bell.Aria"]"in the markup (NotificationBell.razor:10), which is what the injectedIStringLocalizer<SharedResource> L(line 35) is for.- Fire-and-forget from a synchronous event handler.
HandlePollerSlotFreed(lines 109-110),OnLocationChanged(lines 164-170), andHandleRefreshRequested(lines 182-186) are allEventHandler-shaped, so they cannot beasync Task. Rather thanasync void(which turns an unobserved exception into a process crash, VSTHRD100, per the comments at lines 107-108 and 160-163), they discard the task with_ =and rely on the callee observing its own failures.
- Why owner identity matters here. The class remarks (lines 22-29) name the real scenario: hosts render the bell inside
- Walkthrough
- Injected members (lines 32-37):
State,InboxService,NavigationManager,L,IOptions<NotificationBellOptions> Options, andTimeProvider Clock. Fields (lines 39-42): a per-componentCancellationTokenSource _cts, thePeriodicTimer? _pollTimer, and the two flags_isActivePollerand_disposed. OnAfterRenderAsync(bool firstRender)(lines 44-59) returns immediately on subsequent renders (lines 46-49), subscribes every instance to two state events,OnChangeandOnPollerSlotFreed(lines 51-52), then tries for the slot and, on success, callsBecomeActivePollerAsync()(lines 55-58).BecomeActivePollerAsync(lines 67-105) is the double-start-guarded start: it bails on_disposed || _isActivePoller(lines 69-72), latches_isActivePoller, hooksLocationChanged(line 75) andOnRefreshRequested(line 80), does the unconditional first read (line 83), then re-checks_disposedafter that await (lines 89-92). The comment (lines 85-88) explains what that guard prevents:Disposemay have run during the read, having already disposed the then-null timer and the token source, so starting the loop now would leak aPeriodicTimernothing disposes and fault the discarded task on a disposed_cts. It then returns without a timer whenPollInterval <= TimeSpan.Zero(lines 96-99). Only then does it create the timer fromOptions.Value.PollIntervaland the injected clock (line 103) and launchPollLoopAsyncwith an explicit discard (line 104). The method doc (lines 61-66) notes it always runs on the renderer's synchronization context, which is what makes the simple_isActivePollerguard sufficient rather than needing an interlocked operation.TryTakeOverPollingAsync(lines 116-132) is the handover path: it re-checks the guards and claims the slot (line 118), then marshals the actual start onto this component's renderer withInvokeAsync(BecomeActivePollerAsync)(line 125), because the event was raised synchronously from the disposing bell's thread (doc, lines 112-115). If that dispatch hitsObjectDisposedExceptionit hands the slot straight back (lines 127-131) so another bell can claim it.PollLoopAsync(lines 134-152) awaits_pollTimer!.WaitForNextTickAsync(_cts.LifetimeToken())in a loop (line 138) and refreshes each tick. The token comes from ComponentLifetimeExtensions (ComponentLifetimeExtensions.cs:26), which returns an already-cancelled token instead of throwing once the source is cancelled or disposed, so a late read stops through the normal cancellation path. It still catchesOperationCanceledException(the expected disposal exit) andObjectDisposedException(disposed between timer creation and the first wait, where reading the token off the disposed source throws rather than cancelling, comment lines 149-150).RefreshUnreadCountAsync(lines 188-227) is the one place that touches the network: it bails when_disposed(lines 190-193), callsInboxService.GetUnreadCountAsync(_cts.LifetimeToken())(line 197), and returns without touching the badge when the result is a failure (lines 198-204). That early return is load-bearing: the comment (lines 200-202) records that zeroing the badge on an unknown count is what used to erase a push increment, and that a failed read is silent by design because the bell has no surface to report it on. On success it re-checks_disposedand marshalsState.SetUnreadCount(unread)plusStateHasChanged()back onto the renderer withInvokeAsync(lines 206-213). Three catch tiers follow (lines 215-226): cancellation, disposal during the async gap, and a barecatchfor network or deserialization failures where the badge keeps its last value. That catch-all is what makes the discards above safe.HandleStateChanged(lines 229-230) discards intoRerenderSafeAsync(lines 232-247), which re-renders throughInvokeAsync(StateHasChanged)and tolerates a dispose landing between the event firing and the render dispatch.NavigateToInbox(line 249) sends the click toNotificationRoutePaths.NotificationInbox.Dispose(bool disposing)(lines 251-272) sets_disposedfirst (line 258), unsubscribes all three state events andLocationChanged(lines 259-262; removing a handler that a non-poller never added is a no-op), then callsState.UnregisterPoller(this)unconditionally (line 267). The comment (lines 264-266) states both halves of why that is safe: a bell that claimed the slot but was torn down before it started polling still frees it, and a bell that never held it cannot evict the live poller, because the state object checks owner identity. It then disposes the timer and cancels and disposes the_cts(lines 269-271).Dispose()(lines 274-278) is the public half withGC.SuppressFinalize.
- Injected members (lines 32-37):
- Why it's built this way: a live unread badge is a genuinely useful affordance, but a naive implementation is a request amplifier (one timer and one push read per rendered copy, per circuit) and a fragile one under
<AuthorizeView>churn. Owner-identity registration plus a freed-slot event keeps the affordance, removes the amplification, and survives the teardown-rebuild cycle that a plain counter would leave stuck. Making both intervals options and both clocks injected turns the polling policy into something a host can tune and a test can drive. - Where it's used: contributed to the shell as an app-bar component by NotificationUIModule (
NotificationUIModule.cs:23); it reads the same NotificationState that NotificationInbox writes after a mark-read, so the badge stays consistent with the inbox without either component knowing about the other.
TermsAcceptanceGate
MMCA.Common.UI ·
MMCA.Common.UI.Components.Legal·MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/Legal/TermsAcceptanceGate.razor.cs:29· Level 6 · class (partial, component)
- What it is: the code-behind for the blocking consent dialog a signed-in user sees when the current terms version is one they have not accepted. It listens to the authentication state, reads the user's standing once per identity, and shows a modal whose only exits are accepting or signing out.
- Depends on: first-party: ILegalAcceptanceUIService (
GetAsyncandAcceptAsync), IAuthUIService (LogoutAsyncfor the sign-out exit), LegalSettings (the bound options the dialog body reads), LegalAcceptanceDTO (the standing:CurrentVersion,AcceptedVersion,IsCurrent), and SharedResource (the localizer anchor). Externals:Microsoft.AspNetCore.Components.Authorization(AuthenticationStateProvider),MudBlazor(DialogOptions,MaxWidth),Microsoft.Extensions.Options,Microsoft.Extensions.Localization,Microsoft.Extensions.Logging(ILogger<T>),System.Security.Claims(ClaimsPrincipal), BCLCancellationTokenSource,IDisposable. - Concept introduced, a gate that cannot be dismissed by the framework and re-asks only when the identity or the version changes.
[Rubric §19, State Management & Data Flow]assesses how per-user UI state is coordinated across navigation and authentication changes. Three pieces of state keep this honest, all private fields (lines 47-55): the cached_standing, the_checkedUserKeyit belongs to, and the_visibleflag.- Not dismissible, on purpose.
DialogOptions(lines 37-45) turns offBackdropClick,CloseOnEscapeKey,CloseOnNavigationandCloseButton. The summary (lines 31-36) records the real failure behindCloseOnNavigation = false: the dialog provider dismisses every open dialog onLocationChanged, and a list page rewrites its own URL (page, sort, filter) right after loading, which closed the gate for the rest of the session while consent was still owed. - Identity-keyed evaluation.
UserKey(lines 134-135) is the user-id claim, else the identity name, else empty.EvaluateAsync(lines 137-184) returns early when the key equals_checkedUserKey(lines 147-151), so a token refresh that re-raises the authentication event does not re-read the standing; only a change of key (sign-in, account switch) does. An unauthenticated principal clears the key and hides the dialog (lines 139-145). - Stale-response guard. After the awaited read, the method compares the key again and drops the result if the user changed while it was in flight (lines 165-169), because the newer evaluation owns the outcome. The dialog shows only when the read succeeded and
CurrentVersionis non-null andIsCurrentis false (line 171); any failed read hides it, so a transport fault never locks a user out of the app. [Rubric §15, Best Practices and Code Quality]assesses precedent reuse: the discarded-task event handler and its exhaustive catch follow theBiometricGateprecedent named in the comments (lines 247-248, 268-269), see BiometricGate.
- Not dismissible, on purpose.
- Walkthrough
- Injected members (lines 57-76):
AuthStateProvider,LegalAcceptance,AuthService,Navigation,LegalOptions,L, andLogger.Heading(lines 81-83) picks the "first" title whenAcceptedVersionis null and the "updated" title when the user accepted an earlier version. OnAfterRenderAsync(bool firstRender)(lines 93-105) returns on later renders, subscribes toAuthenticationStateChanged(line 100), sets_subscribed, then evaluates the current principal (lines 103-104).AcceptAsync(lines 186-230) requires a version, the agreement checkbox, and!_busy(lines 188-192). On success it hides the dialog (lines 199-203). If the server refuses withLegalAcceptanceErrorCodes.VersionNotCurrent, the version moved while the dialog was open, so it clears the cached key and re-evaluates (lines 207-213), showing the version that is current now instead of retrying a stale one. Any other failure sets the error text: a validation refusal uses the server-phrased message, everything else (transport fault, timeout, server error) uses the gate's own localizedLegal.Gate.AcceptFailed(lines 218-220, comment lines 215-217). Cancellation is swallowed (lines 222-225) and_busyis cleared infinally.SignOutAsync(lines 232-237) sets_busy, callsAuthService.LogoutAsync()(line 235), and navigates to/loginwithforceLoad: true(line 236) so the circuit and its state are discarded.OnAuthenticationStateChanged(line 249) discards intoReevaluateAsync(lines 251-272), which awaits the new state, marshalsEvaluateAsyncthroughInvokeAsync, and catchesObjectDisposedException,InvalidOperationException, and finally a bareExceptionthat is logged (lines 266-270); the last tier exists because the event handler discards the task, so nothing else would observe a fault.Dispose(bool)(lines 109-128) unsubscribes only if subscribed, cancels_disposalCtsonce, and disposes it; the token source is passed to both service calls (lines 158, 198), so teardown mid-request ends through the cancellation path.
- Injected members (lines 57-76):
- Why it's built this way: consent is owed, so the gate must survive everything that normally closes a dialog (navigation, Escape, backdrop) and must not nag when nothing changed (token refresh) or when it cannot know (failed read). Keying the cache on identity and re-reading on a version-conflict code keeps it correct across account switches and mid-dialog version bumps. See ADR-116 for the identity-completions context.
- Where it's used: contributed as a layout component by the ADC
IdentityUIModule(LayoutComponentTypes = [typeof(TermsAcceptanceGate)],MMCA.ADC.Identity.UI/IdentityUIModule.cs:40); the client it calls, ILegalAcceptanceUIService, is registered for every host (MMCA.Common.UI/DependencyInjection.cs:168).
DependencyInjection
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs:20· Level 14 · class (static)
- What it is: the registration extensions for the server-side Blazor Web host pieces this package ships: four
IServiceCollectionmethods a host calls fromProgram.csinstead of registering app-local copies of the token store (which now also forwards the visitor's origin on server-side API calls), the CSP provider (now including its options and their startup validation), the form factor, and the trusted-caller rate-limit exemption. - Depends on: first-party: ServerTokenStorageService + ITokenStorageService, BrowserOriginHandler (the
"APIClient"message handler), BlazorCspPolicyProvider + ICspPolicyProvider, BlazorCspSettings (the bound"BlazorCsp"options) + BlazorCspSettingsValidator (itsIValidateOptions<BlazorCspSettings>), WebFormFactor + IFormFactor, TrustedCallerHandler (the delegating handlerAddTrustedCallerHeadercomposes), GatewayRateLimitingSettings (the shared configuration section for both ends) and ApiSettings (the server-side endpoint the origin gate is built from). Externals:Microsoft.Extensions.DependencyInjection(IServiceCollection,AddScoped,AddSingleton,AddHttpContextAccessor,ConfigureAll<HttpClientFactoryOptions>),Microsoft.Extensions.DependencyInjection.Extensions(TryAddEnumerable),Microsoft.Extensions.Options(AddOptions,BindConfiguration,ValidateOnStart),Microsoft.Extensions.Configuration(IConfiguration). - Concept: the same
extension(IServiceCollection services)block idiom used package-wide (line 22, see primer). What is worth studying here is that the XML docs carry operational rules the compiler cannot enforce, and they are the only place those rules are written down next to the code.[Rubric §15, Best Practices and Code Quality]assesses idiom consistency; everyMMCA.Common.*package registers services through the same extension shape, so a reader who has seen one registrar has seen them all.[Rubric §26, Front-End Security]assesses browser hardening wiring;AddCommonBlazorCsp()is what actually puts BlazorCspPolicyProvider in front of the default static provider, and its doc (lines 56-58) encodes the ordering rule: call it beforeAddCommonSecurityHeaders, because the default is registered withTryAddand would otherwise win.[Rubric §22, Configuration and Secrets Management]assesses whether a bad configuration value is caught before it can silently degrade a live host;ValidateOnStart()(line 71) is what turns a badBlazorCsp:FrameSourcesentry into a boot failure instead of a policy that quietly omits the frame source.[Rubric §19, Rate Limiting and Throttling]assesses whether a legitimate internal caller can be told apart from the anonymous crowd it is partitioned with;AddTrustedCallerHeaderis that opt-in exemption for this host's own server-to-server calls.
- Walkthrough
AddCommonServerTokenStorage()(lines 41-51): callsservices.AddHttpContextAccessor()(line 43), the accessor ServerTokenStorageService needs to tell SSR from circuit. It then registers BrowserOriginHandler as a transient and appends it to the named"APIClient"client withAddHttpClient("APIClient").AddHttpMessageHandler<BrowserOriginHandler>()(lines 47-48). The doc (lines 33-40) says each server-side request then carriesX-Forwarded-Forset to the remote IP andUser-Agentset to the browser's user-agent of the HTTP request behind the render (the page request during prerender, the circuit's connection afterwards), so per-client limits such as the registration rate limit key on the visitor rather than on this host, and a sign-in records the visitor's device; nothing is sent when no request is in scope. The comment (lines 45-46) notes a secondAddHttpClientwith the same name appends to the client registered byAddUIShared, whichever runs first. Finally it registers ServerTokenStorageService as the scoped ITokenStorageService (line 50). Scoped is the right lifetime: a circuit is a DI scope, so the in-memory access token is per-session state. The doc (lines 24-31) names the two companions this registration assumes,AddServerAuthSessionCookieandUseCookieSessionRefreshfromMMCA.Common.API, plus a registered ITokenRefresher.AddCommonBlazorCsp()(lines 67-78) now does three things, not one. It binds BlazorCspSettings from the"BlazorCsp"configuration section and callsValidateOnStart()(lines 69-71); it registers BlazorCspSettingsValidator as anIValidateOptions<BlazorCspSettings>throughTryAddEnumerable, so calling this method twice never runs the same validation twice (comment at line 73, registration lines 74-75); and it registers BlazorCspPolicyProvider as a singleton ICspPolicyProvider (line 77), matching the provider's compute-once constructor.AddSingleton(notTryAdd) is what makes the replacement deterministic. The doc (lines 60-64) states the configuration contract:BlazorCsp:FrameSourceslists the https origins the host's pages may frame, emitted asframe-src 'self' <origins>; an absent or empty list leaves the policy unchanged, and an invalid entry fails the boot rather than being silently dropped.AddCommonWebFormFactor()(lines 85-86): registers WebFormFactor as a singleton IFormFactor, which reports "Web" plus the server OS description; the doc (lines 81-84) notes the WASM client registersAddWasmFormFactor()fromMMCA.Common.UIinstead, so the same abstraction resolves differently per host kind.AddTrustedCallerHeader(IConfiguration configuration)(lines 121-159) is opt-in: it reads GatewayRateLimitingSettings and, whenTrustedCallerSecretis blank (the local/CI default), orTrustedCallerHeaderNameis blank, or the server-sideApiSettings.ApiEndpointis missing or not an absolute URI, registers nothing at all (lines 133-138). The remarks (lines 96-117) give three reasons in full: opt in, no secret means every call stays rate limited exactly as today, and the gateway must read the sameGatewayRateLimitingsection so one section configures both ends; server only, the secret must reach this SSR host alone (GatewayRateLimiting__TrustedCallerSecret) and never the WebAssembly client or a rendered page; and why every client, the cookie-session token-refresh client is created under a name a host cannot reach, so the composition targets everyHttpClientthis host builds viaservices.ConfigureAll<HttpClientFactoryOptions>(...)(lines 152-156) rather than one named client. When it does register, it inserts TrustedCallerHandler atAdditionalHandlers.Insert(0, ...)(lines 154-156), notAdd: the comment (lines 143-151) explains that index 0 is the outermost handler, so the origin gate judges the authority the caller configured rather than the authority Aspire's service-discovery handler rewrites mid-pipeline, and that ordering holds regardless of whether a host callsAddServiceDefaultsbefore or after this method.
- Why it's built this way: all four pieces are host-level infrastructure that carry no app-specific state, so they were hoisted into
MMCA.Common.UI.Weband exposed as one-line registrations. Binding and validating BlazorCspSettings insideAddCommonBlazorCsp()itself, rather than leaving it to the host, means a host that opts intoframe-srcgets the fail-fast contract for free instead of having to know to add its ownValidateOnStart().AddTrustedCallerHeaderfollows the deployment's gateway rate-limiting policy: a per-client-IP partition that a trusted internal caller would otherwise collapse into, exempted only when the deployment explicitly configures a shared secret. That keeps every consumer'sProgram.csfree of duplicated token-store, CSP, form-factor and rate-limit-exemption wiring, which is the reusable-building-blocks charter of this group. See ADR-022 for the session design the first method plugs into, and ADR-070 for the fail-fast-on-boot postureValidateOnStart()follows. - Where it's used: called from the
Program.csof the server-interactive Blazor Web hosts in the consumer apps (MMCA.ADC, MMCA.Store). - Caveats / not-in-source: the doc comment and the provider's fail-closed
connect-src 'self'fallback now agree (BlazorCspPolicyProvider.cs:60-62); which host binds aBlazorCsp:FrameSourceslist, if any, is not visible from this file.
⬅ Module System, Composition & Configuration • Index • Aspire Orchestration & Service Defaults ➡