F45: i18n infrastructure (API half) — /me, language, errorCodes, idempotency user-scope#229
Merged
Conversation
added 8 commits
July 25, 2026 20:45
… + normalization tests (#45)
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.
Closes #45 — the API half of i18n infrastructure. English-only; the SPA half is #182 and depends on
GET /me+ theerrorCodesextension shipped here.What this adds
ApplicationUser.Language— nullablevarchar(16)onAspNetUsers(a BCP-47 primary language subtag, stored lowercased). Not a locale: regional/number/date formatting stays a farm-scopedAccountconcern (§4.5).GET /api/v1/me— new user-scopedMeEndpointsgroup returning{ id, email, name, role, language }. Identity from the JWTsub; role echoed (JWT stays authoritative, highest-role-wins).PUT /api/v1/me/language— self-service, open to every role incl. ReadOnly (bare default policy, no named write policy). The target user id comes from the token, never the body; the write is account-scoped. Canonicalizes (trim + lowercase);nullclears; grammar^[A-Za-z]{2,8}$; an absent field is a 400.errorCodeson validation 400s (ValidationResponse) — a parallel, index-aligned{ field: ["Feature.Field.Rule" | null] }companion toerrors. Explicit-only: a code is emitted iff a validator set one via.WithErrorCode(...)(discriminator: contains a.), so FluentValidation's built-in codes never leak.errorsis byte-for-byte unchanged. Routed all 34 FluentValidation + 4 hand-built 400 sites through the helper;Me.Language.Formatis the worked example./api/v1/mepaths, so two users in one account can't skip/replay each other's per-user write. All other endpoints keep the account-only key unchanged.Deliberate decisions / deviations
SetLanguageAsyncusesExecuteUpdateAsync(no audit, no concurrency token) — a UI-language toggle is a personal preference, not a critical/audited change; last-write-wins on one's own field is benign. MirrorsRevokeRefreshTokenAsync.requiredmember (a malformed-body 400) — which the issue itself classes as out of the coded contract (absent = malformed request, not field validation)./mepaths rather than global (the issue allowed "a per-endpoint override"). This resolves a review finding: a global fold would change the key for every endpoint, so a same-user retry spanning a deployment could re-execute an account-scoped write (e.g. a sales order). Scoping to/meleaves money-write endpoints' hashes untouched — no cross-deploy re-execution window.Out of scope (tracked follow-ups)
GET /me+errorCodes. The Help-page (F18: In-app help — user guide + glossary in the SPA #71) update rides with it (no language UI exists yet).<Feature>.<Field>.<Rule>codes to the remaining 30 validators — incremental, tracked in i18n infrastructure (API half): validation error codes + users.language + GET/PATCH /me #45; until a validator has explicit codes it emits none..WithErrorCode(...)value contains a.(one call site today).Testing
Full solution green: Domain 283 · Application 70 · API integration 397 · build 0 warnings/0 errors. New coverage: column round-trip;
errorCodeshelper unit tests (leak-guard + index-alignment);GET /meshape incl. ReadOnly;PUT /me/languageevery role incl. ReadOnly, null-clears," EN "→"en",en-US/empty/too-long → coded 400, absent → 400; cross-user + same-user idempotency; additive-onlyerrorCodesregression guard.Review
Reviewed per policy — codex + two Claude agents + pi, plus per-task reviews across all 7 tasks.
/me(above).requiredfield ignored →{}clears — false, theAbsent_field_is_a_400integration test returns a real 400 (.NET 10 enforcesrequiredin minimal-API binding); (2) NRE ontenant.AccountId— false, the!tenant.IsResolvedguard skips before any access./me/*returns per-user identity data and is now user-scoped; other cached writes are account-scoped by design (POST/PUT on shared tenant resources, returning account-visible ids or 204 —/me/languageitself is 204). Replaying a co-tenant's response there crosses no data boundary, and the SPA mints a fresh key per call so a collision is practically unreachable. This account-scoped behavior pre-existed the PR; the PR narrowed the sensitive/mecase.Migration
One additive migration (
AddUserLanguage) — a nullable column, no default, backward-compatible. No data migration needed.