Skip to content

F45: i18n infrastructure (API half) — /me, language, errorCodes, idempotency user-scope#229

Merged
mforce merged 8 commits into
mainfrom
f45-i18n-api
Jul 26, 2026
Merged

F45: i18n infrastructure (API half) — /me, language, errorCodes, idempotency user-scope#229
mforce merged 8 commits into
mainfrom
f45-i18n-api

Conversation

@mforce

@mforce mforce commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Closes #45 — the API half of i18n infrastructure. English-only; the SPA half is #182 and depends on GET /me + the errorCodes extension shipped here.

What this adds

  • ApplicationUser.Language — nullable varchar(16) on AspNetUsers (a BCP-47 primary language subtag, stored lowercased). Not a locale: regional/number/date formatting stays a farm-scoped Account concern (§4.5).
  • GET /api/v1/me — new user-scoped MeEndpoints group returning { id, email, name, role, language }. Identity from the JWT sub; 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); null clears; grammar ^[A-Za-z]{2,8}$; an absent field is a 400.
  • Additive errorCodes on validation 400s (ValidationResponse) — a parallel, index-aligned { field: ["Feature.Field.Rule" | null] } companion to errors. Explicit-only: a code is emitted iff a validator set one via .WithErrorCode(...) (discriminator: contains a .), so FluentValidation's built-in codes never leak. errors is byte-for-byte unchanged. Routed all 34 FluentValidation + 4 hand-built 400 sites through the helper; Me.Language.Format is the worked example.
  • Idempotency user-scoping — the current user id is folded into the key hash only for /api/v1/me paths, 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

  • 34, not "35" FluentValidation sites (the issue's estimate was off by one; verified four ways).
  • SetLanguageAsync uses ExecuteUpdateAsync (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. Mirrors RevokeRefreshTokenAsync.
  • Absent-field 400 is delivered via a required member (a malformed-body 400) — which the issue itself classes as out of the coded contract (absent = malformed request, not field validation).
  • Idempotency fold scoped to /me paths 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 /me leaves money-write endpoints' hashes untouched — no cross-deploy re-execution window.

Out of scope (tracked follow-ups)

Testing

Full solution green: Domain 283 · Application 70 · API integration 397 · build 0 warnings/0 errors. New coverage: column round-trip; errorCodes helper unit tests (leak-guard + index-alignment); GET /me shape incl. ReadOnly; PUT /me/language every role incl. ReadOnly, null-clears, " EN ""en", en-US/empty/too-long → coded 400, absent → 400; cross-user + same-user idempotency; additive-only errorCodes regression guard.

Review

Reviewed per policy — codex + two Claude agents + pi, plus per-task reviews across all 7 tasks.

  • codex flagged the global-idempotency cross-deploy window → resolved by scoping the fold to /me (above).
  • pi raised two blockers, both refuted: (1) required field ignored → {} clears — false, the Absent_field_is_a_400 integration test returns a real 400 (.NET 10 enforces required in minimal-API binding); (2) NRE on tenant.AccountId — false, the !tenant.IsResolved guard skips before any access.
  • Cross-user response replay (background security review): acknowledged, not a regression. Only /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/language itself 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 /me case.

Migration

One additive migration (AddUserLanguage) — a nullable column, no default, backward-compatible. No data migration needed.

@mforce
mforce merged commit 076e304 into main Jul 26, 2026
7 checks passed
@mforce
mforce deleted the f45-i18n-api branch July 26, 2026 05:57
@mforce mforce mentioned this pull request Jul 26, 2026
57 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

i18n infrastructure (API half): validation error codes + users.language + GET/PATCH /me

1 participant