feat(localization): request localization, localized errors and user locale (1/4) - #1360
Open
marcelo-maciel wants to merge 1 commit into
Open
feat(localization): request localization, localized errors and user locale (1/4)#1360marcelo-maciel wants to merge 1 commit into
marcelo-maciel wants to merge 1 commit into
Conversation
…ocale Framework slice of the i18n work (split of fullstackhero#1344 as requested in review). - `SharedResources` catalog (en + pt-BR) and `SupportedCultures` as the single source of supported tags. - `CustomException` carries `MessageKey`, `MessageArgs` and `ResourceSource`; `Message` stays English so logs remain culture-independent. `ILocalizableMessage` subclasses keep `UnauthorizedAccessException` / `KeyNotFoundException` as base types so audit severity classification is unaffected. - `GlobalExceptionHandler` localizes `title`/`detail` and surfaces the message key as a stable `code` extension on ProblemDetails. - `UseHeroLocalization` request-localization chain, UI-culture-only: `CurrentCulture` stays invariant, only `CurrentUICulture` is negotiated. `UserLocaleRequestCultureProvider` reads the `locale` claim, so the middleware sits between `UseAuthentication` and `UseAuthorization`. - `User.Locale` (`varchar(10)`, nullable, no database default; `en-US` is a code-level fallback) plus the `AddUserLocale` migration, the `locale` claim emission and the write-boundary validator rejecting tags outside `SupportedCultures.Tags`. - `LogContext.PushProperty` scoped in `using` blocks, fixing a pre-existing AsyncLocal leak that contaminated later log entries in the same request. - `SSH.NET` pin (`2026.0.0`), byte-identical to fullstackhero#1333, so `dotnet restore` passes while that PR is open.
This was referenced Aug 17, 2026
marcelo-maciel
marked this pull request as ready for review
August 17, 2026 04:02
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Framework slice of the i18n work, split out of #1344 as you asked. This is the one that needs real scrutiny; 56 files.
The split is four PRs rather than three. Your three were framework /
clients/admin/clients/dashboard, but the ~220 files of module-level localization fit none of them, and folding them into the framework PR would put it back at ~285 files and defeat the point. So the module catalogs and their handler/validator wiring live in their own PR.clients/adminclients/dashboardThe union of the four is byte-identical to
#1344's tree, with empty pairwise intersection apart from the one sharedsrc/Directory.Packages.propshunk. That is asserted by a script, not by eye: for each slice,git diff --quiet feat/i18n <slice> -- <its paths>andgit diff --name-only main <slice>equal to its declared path set.The two front-end PRs depend on nothing here and can be reviewed in parallel. The module PR does not compile without this one — verified, not assumed:
src/Modules/**applied alone onmainfails with 862 compile errors, all rooted inFSH.Framework.Core.Localizationnot existing.src/BuildingBlocks(Golden Rule #4)Seventeen files here, needing maintainer sign-off:
Core—Core.csproj;Exceptions/(CustomException,ForbiddenException,UnauthorizedException, and the newILocalizableMessage,LocalizedKeyNotFoundException,LocalizedUnauthorizedAccessException);Localization/(newSharedResourcesmarker +SupportedCultures+ the two shared catalogs).Web—Extensions.cs(registers and orders the localization middleware, +6 lines);Exceptions/GlobalExceptionHandler.cs; newLocalization/(LocalizationExtensions,UserLocaleRequestCultureProvider).Jobs—Extensions.cs, one exception message.Storage—QuotaMeteredStorageService.cs, one exception message.One eighteenth
BuildingBlocksfile is in the module PR instead, and I want to be upfront about it:Web/Validation/PagedQueryValidator.cs. Its constructor now takesIStringLocalizer<SharedResources>, and all three subclasses (GetAuditsQueryValidator,GetTenantsQueryValidator,SearchUsersQueryValidator) live in modules. Keeping the base class here would either break this PR's build or drag the Auditing catalog and handler in with it; sending it with its three callers keeps both PRs compiling on their own and puts the change in front of the code it affects. It is declared under Golden Rule #4 there too.No existing behaviour of other building blocks is altered.
src/Directory.Packages.propscarries one addition, theSSH.NETpin discussed at the end.UseRequestLocalizationsets the UI culture onlyYou asked whether UI-culture-only was considered. It is what ships.
mainhas no request localization at all, so pinning the formatting culture is less change than negotiating it:CultureInfo.CurrentCulturebehaves exactly as it does onmaintoday, and only resource lookup follows the request. For an API whose output is JSON that is the safer default, and it makes the CA1305 question moot rather than merely bounded.It is not one switch.
RequestLocalizationMiddleware.SetCurrentThreadCultureassigns both cultures unconditionally, so the culture half has to be pinned:DefaultRequestCulturecarries(InvariantCulture, configured default). The middleware resolves the culture half ascultureInfo ??= DefaultRequestCulture.Culture, making invariant the only reachable value.SupportedCulturesisnull, so the middleware skips culture filtering entirely. A one-element[InvariantCulture]list behaves identically but logsUnsupportedCultureson every request — the middleware's parent-culture walk bails at the empty culture name, so invariant is unmatchable by design.With formatting out of the negotiation, the neutral
pt/enentries inRequestMatchbought nothing and are gone;SupportedCultures.Tagsis the single, specific-only list. A request asking for a bareptor an unsupported variant resolves to the configured default. Both React apps canonicalise variants onto supported tags before calling the API, so app traffic is unaffected; a hand-rolled client sending bareptgets the default.Message arguments are culture-insensitive too. The localizer formats with
string.FormatunderCurrentCulture, so adoubleorDateTimein a message would render with an invariant separator. EveryMessageArgssite and everylocalizer["…", …]call site was enumerated: allint,long,stringor enum, exceptMaxWindow.TotalDaysin the two audit-window validators, which is now anintat the source (that change travels with the module PR).Catalogs are named for specific cultures
SharedResources.pt-BR.resxhere, and the same convention for the ten module catalogs in the module PR. Renames only, no string changed.The asymmetry with the front-end is gone, and so is the trap behind it: a future
pt-PTis no longer served Brazilian strings by parent fallback. The documented consequence is that a bareptor an unsupported variant lands on the neutral English catalog rather than on Portuguese. Adding a language is: add the specific tag toSupportedCultures.Tags, add a*.{tag}.resxper catalog, add the JSON catalogs to both apps, and drop it from the front-endCANONmap if it was being folded into another tag..agents/rules/localization.mdrecords all of this.The
LocalecolumnThe original summary was wrong: there is no DB default. The column is nullable with
en-USas a code-level fallback, and it ischaracter varying(10)rather than unboundedtext— 10 covers language-script-region (zh-Hant-TW).AddUserLocalewas edited in place rather than stacked with anALTER, since it has never shipped in a release.Confirmed as you asked:
Validation.UnsupportedLocaleis wired at the write boundary.UpdateUserCommandValidatorrestrictsLocaletoSupportedCultures.Tags, onPUT /identity/profile, via the MediatorValidationBehavior. The column constraint is the storage-level backstop, not the validation.Because whole files cannot be split across PRs, three Identity files carry both the
Localeplumbing and theirIdentityResourceswiring in the same diff (IdentityService,UserProfileService,StartImpersonationCommandHandler). They ship here, which is whyIdentityResourcesand its two catalogs ride along in this PR rather than in the module one.Known behaviour (documented, not bugs)
localeclaim lags a language switch by one token. The provider reads the JWT claim, so a switch reaches the API at the next token issue. The front-end persists to the profile and re-mints, so it converges; in between, the shell can be in the new language while an API error is still in the old one. The alternative is a per-request DB read on every authenticated call.apiFetch, soAccept-Languageon the negotiate is the browser's. Applies to every session, not just impersonation. Named explicitly in the front-endhandoff-locale.spec.tsso any other channel that stops carrying the locale fails the test.UseExceptionHandler()sits ahead ofUseHeroLocalization(), which in turn has to sit afterUseAuthentication()because the culture provider reads thelocaleclaim offHttpContext.User. An exception thrown by anything in between — HTTPS redirection, CORS, static files, routing — is therefore rendered in the configured default culture rather than the caller's. Endpoint handlers, where every localized exception in this codebase is actually thrown, are unaffected. Moving the exception handler below localization would leave those middlewares with noProblemDetailsat all, which is the worse trade, so this stays as documented behaviour rather than being papered over.Also in this slice, from the last review round
LogContext.PushPropertyis scoped inusingblocks. Pre-existing AsyncLocal leak that contaminated every subsequent log entry in the request; unrelated to i18n, fixed here because the same lines were being touched.TitleKeyForsent everything outside four statuses toError.Unexpected; the type-name fallback beside it only fires onResourceNotFound, and that key resolves, so it never fired.#1344regressed this — before it,Titlewas the exception type name. Unmapped statuses fall back to the type name again, andConflictgets a real localized title. The 41Conflictthrow sites this affected are in Billing and Catalog, so the visible half of that fix lands with the module PR.ExceptionSeverityClassifieris now exercised with theLocalized*subclasses. They subclass the BCL types precisely so audit severity classification keeps working; changing a base type would have silently reclassified every unauthorized access with the suite green.The
SSH.NETpin is carried from #1333NU1903/GHSA-q939-rpr3-3284onSSH.NET2025.1.0, pulled transitively by Testcontainers, failsrestorefor the whole solution underTreatWarningsAsErrors— onmaintoo:dotnet restore src/FSH.Starter.slnxat3f2959e6fails identically, re-verified today. It is not introduced here, and the fix properly belongs to #1333.Rather than leave this red on someone else's advisory, the pin is carried byte-identical to #1333's version of the file, comment included. That keeps both mergeable in either order:
git merge-treeon both orderings yields a clean tree holding a singleSSH.NETentry, and the merged file is identical to what #1333 alone produces. The byte identity is what buys that — the same pin under a reworded comment conflicts. If #1333's pin changes during review this copy should be matched rather than allowed to drift, and once #1333 lands first it can simply be dropped. All four slices carry it, including the two front-end ones:template-smoke.ymlruns onclients/**and builds the scaffolded solution, which includessrc/Tests/**.Testing
Every number below is this slice on its own, at
mainplus these 56 files.dotnet restore src/FSH.Starter.slnxwith the audit on: exit 0, noNU1903.dotnet build -warnaserror: exit 0.#1344reported 15 assemblies and 1891 passed; the missing assembly isTickets.Tests, which the module PR adds to the solution, and the 23-test difference is that project plus the module-catalog tests. Nothing was dropped — the per-slice sums add back up.The verdict above is aggregated per assembly rather than taken from the process exit code:
dotnet teston this solution has been observed exiting 0 while reporting failures, and zero assemblies reporting is itself treated as red.Docs (Golden Rule #10)
fullstackhero/docs#238, kept as a single PR covering all four slices —
internationalization.mdxis one page whose sections map across the split, so cutting it into four would put four PRs on the same file and leave three describing half a feature. From this slice it documents the culture resolution chain, per-user language, the new config section andcodeonProblemDetails, all of which are public contract.It should merge after the last of the four, not with this one: landing it here alone would publish the module-catalog and front-end sections before that code is on
main.Notes
en-USandpt-BRare held at strict key and placeholder parity, enforced by tests, so a missing or mis-arged translation fails the build instead of shipping English. The generic reflection-drivenCatalogParityTeststhat covers every module catalog travels with the module PR.PUT /identity/profileand is tracked separately in #1359.