Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
5631864
docs: document internationalization (i18n)
marcelo-maciel Jul 20, 2026
ed6bc4b
docs: document the ProblemDetails code extension
marcelo-maciel Jul 24, 2026
769181e
Merge remote-tracking branch 'origin/main' into docs/i18n
marcelo-maciel Aug 17, 2026
3f57a84
docs(i18n): retarget the code anchor after the em dash sweep changed …
marcelo-maciel Aug 17, 2026
bfcb772
Merge remote-tracking branch 'upstream/main' into docs/i18n
marcelo-maciel Sep 17, 2026
0f4f1d9
docs(i18n): name the localStorage key the detector actually writes
marcelo-maciel Sep 18, 2026
2e391e9
Merge upstream/main into docs/i18n
marcelo-maciel Sep 25, 2026
4f9b26f
docs(i18n): describe the localization the code actually ships
marcelo-maciel Sep 25, 2026
9b7b518
docs(i18n): note the container's invariant globalization and the Pred…
marcelo-maciel Sep 25, 2026
2534a10
docs(i18n): only English must be complete; parity fails on orphans an…
marcelo-maciel Oct 2, 2026
14da348
docs(i18n): non-default languages load on demand, one chunk per language
marcelo-maciel Oct 2, 2026
952a3c2
docs(i18n): exceptions attach keys with .Localized and missing keys f…
marcelo-maciel Oct 14, 2026
8814531
docs(i18n): frontend keys are typed and the lint blocks untranslated …
marcelo-maciel Oct 15, 2026
3a3146d
docs(i18n): list the breaking changes for existing projects
iammukeshm Nov 3, 2026
b3f0bbd
Merge remote-tracking branch 'origin/main' into land-238
iammukeshm Nov 3, 2026
c743e9d
docs(changelog): date the i18n entry for its merge and link the upgra…
iammukeshm Nov 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/content/docs/changelog/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Notable changes to the kit, newest first.

## 2026-11-03

- **Internationalization (i18n): the kit is now localized end-to-end, with per-user language and `en-US` / `pt-BR` included.** A user's language lives on their account (`User.Locale`, a nullable BCP 47 tag) and rides on the JWT as a `locale` claim, so a signed-in user gets the same language in the UI and in server-produced messages across any browser. **Backend** localizes through `IStringLocalizer` (a shared `SharedResources` catalog in the Core building block plus one `{Module}Resources` catalog per module, each an English neutral resx and a `*.pt-BR.resx` named for the specific culture; `SupportedCultures.Tags` lists only specific tags, so a bare `pt` or an unsupported variant such as `pt-PT` resolves to the default culture) and a `RequestLocalization` pipeline (`AddHeroLocalization` / `UseHeroLocalization`) whose culture-resolution chain tries, in order, a `?culture=` query override, the JWT `locale` claim, `Accept-Language`, and the default culture (`LocalizationOptions:DefaultCulture` when it is supported, otherwise `en-US`). Only the UI culture is negotiated: `CultureInfo.CurrentCulture` stays invariant, so server-side formatting never shifts per caller and message arguments must be culture-insensitive. Exceptions carry a resource key attached with `.Localized<TResource>(key, args)` (or `.Localized(key, args)` for the shared catalog) and validators resolve through `IStringLocalizer`, so problem-details, FluentValidation and 401 challenge messages come out in the resolved culture. **Both React apps** localize with `react-i18next` + `i18next-browser-languagedetector` (JSON catalogs per namespace under `src/locales/<locale>/`, detection cached in `localStorage` under `fsh.admin.lng` / `fsh.dashboard.lng`), send the active language on `apiFetch`'s `Accept-Language`, format dates/numbers/currency locale-aware, and expose a topbar **language switcher** that persists the choice to `User.Locale` through `PUT /identity/profile` and refreshes the token. Per-deployment default is set via `LocalizationOptions:DefaultCulture` (backend) and `config.json` `defaultLanguage` (front-ends). Because a localized `detail` can no longer be pattern-matched, `GlobalExceptionHandler` now emits the exception's `MessageKey` as a **`code` ProblemDetails extension** - a stable, culture-independent discriminator clients branch on instead of the prose. Only the English catalogs have to be complete: a missing `pt-BR` key renders English, and the parity tests fail only on an orphan key or a placeholder that differs, so no contribution has to include Portuguese. A key referenced in code but missing from its catalog fails the build (`LocalizedKeyReferenceTests` on the backend; typed `t()` keys under `tsc -b` in both React apps), and untranslated JSX text in `src/pages` / `src/components` fails `npm run lint` (`eslint-plugin-i18next`). See [Internationalization](/docs/cross-cutting-concerns/internationalization/) and [Error handling](/docs/cross-cutting-concerns/error-handling/#code---the-machine-readable-discriminator). Existing projects should read [Upgrading an existing project](/docs/cross-cutting-concerns/internationalization/#upgrading-an-existing-project), because the ProblemDetails `title`, the `PagedQueryValidator<T>` constructor and the `CustomException` message setters changed. Shipped in [#1381](https://github.com/fullstackhero/dotnet-starter-kit/pull/1381), [#1382](https://github.com/fullstackhero/dotnet-starter-kit/pull/1382), [#1383](https://github.com/fullstackhero/dotnet-starter-kit/pull/1383) and [#1384](https://github.com/fullstackhero/dotnet-starter-kit/pull/1384).
- **Authorization: a route group's `.RequirePermission()` still applies when an endpoint inside it adds its own (fix).** `RequiredPermissionAuthorizationHandler` read only the most specific permission metadata, so `.RequirePermission(b)` on an endpoint replaced `.RequirePermission(a)` on its route group and a user holding only `b` got through. The handler now combines every permission gate on the endpoint and requires all of them, the same all-of rule as `.RequirePermission(a, b)`. A blank gate anywhere in the chain denies the request. No kit endpoint gates a route group today, so nothing changes for the shipped API; your own group-gated endpoints become stricter. See [Authorization](/docs/security/authorization/#applying-a-gate), [#1429](https://github.com/fullstackhero/dotnet-starter-kit/issues/1429) and [#1452](https://github.com/fullstackhero/dotnet-starter-kit/pull/1452).
- **Identity: `PUT /api/v1/identity/profile` no longer has an `email` field (breaking, fix).** `UpdateUserCommand.Email` was validated as an e-mail address but never saved, so a client that sent a new address got a `200` and nothing changed. Changing an e-mail needs a confirmation flow, so the field is removed from the command and its validator rather than wired through. The API's JSON options skip unknown properties, so a client that still sends `email` gets the same `200` and unchanged address as before, but a malformed address is no longer rejected with a `400`. The dashboard's profile save stopped sending it. See [Identity](/docs/modules/identity/), [#1431](https://github.com/fullstackhero/dotnet-starter-kit/issues/1431) and [#1454](https://github.com/fullstackhero/dotnet-starter-kit/pull/1454).
- **Identity: narrowing a permission now revokes it from the built-in roles on existing tenants (fix, behaviour change).** `RolePermissionSyncer` used to only add the permissions missing from `Basic` and `Admin`, so a permission that stopped being `IsBasic`, or that a module stopped registering, stayed on those roles in every tenant that already existed, and the API refuses permission edits on system roles, so it could only be removed by hand in `RoleClaims`. The sync is now authoritative for those two roles: at every API start it also removes each permission claim the catalog no longer grants to the role (the root tenant's `Admin` keeps the `IsRoot` permissions), logs each removal at Warning with the role, the permission and the tenant, and invalidates the permission cache so live sessions lose it on their next request. Only claims of type `permission` are reconciled, and custom roles are untouched. **Upgrade note:** if you narrowed `IsBasic` or removed a permission in your fork, the next start revokes it from `Basic` (or `Admin`) in every existing tenant; check the Warning lines after deploying. If you run another host that registers `IdentityModule` with only some of the other modules (a worker, say), set the new **`PermissionSyncOptions:RemoveRevoked`** to `false` there before upgrading (default `true`), or its partial catalog revokes the missing modules' permissions in every tenant at startup; with it off, that host only adds. A role whose catalog target is empty is never stripped, and an instance that loses a removal race with another one starting at the same time logs it at Information instead of Error. During a rolling deploy, an old-version instance that restarts re-adds a revoked claim until the next start of a new-version instance. See [Authorization](/docs/security/authorization/#the-role-permission-syncer), [hosts that load only some modules](/docs/security/authorization/#hosts-that-load-only-some-modules) and [#1426](https://github.com/fullstackhero/dotnet-starter-kit/pull/1426).
Expand Down
20 changes: 19 additions & 1 deletion src/content/docs/cross-cutting-concerns/error-handling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Plus `ValidationException` from FluentValidation (returned as 400 with the field
| Exception | Status | Notes |
|---|---|---|
| `FluentValidation.ValidationException` | 400 | Per-field `errors` map in the response |
| `CustomException` (incl. subclasses) | `e.StatusCode` | `title` = exception type name, `detail` = message, `errors` = `ErrorMessages` when present |
| `CustomException` (incl. subclasses) | `e.StatusCode` | `title` = localized title for the status (falls back to the exception type name), `detail` = message, `errors` = `ErrorMessages` when present, `code` = `MessageKey` when set |
| `UnauthorizedAccessException` | 401 | BCL fallback |
| `KeyNotFoundException` | 404 | BCL fallback |
| `BadHttpRequestException` | Its own `StatusCode` (usually 400) | Malformed request - missing required header/param, unreadable or oversized body |
Expand All @@ -48,6 +48,24 @@ The `BadHttpRequestException` mapping matters more than it looks: a request to a

Every response also carries `traceId` (the OpenTelemetry trace id, falling back to `HttpContext.TraceIdentifier`) and `correlationId` (the `X-Correlation-ID` request header when present) as ProblemDetails extensions.

### `code` - the machine-readable discriminator

When the thrown exception carries a `MessageKey` (see [Internationalization](/docs/cross-cutting-concerns/internationalization/)), the handler also surfaces that key as a `code` extension:

```json
{
"title": "Forbidden",
"status": 403,
"detail": "This tenant has been deactivated. Contact your administrator.",
"code": "Multitenancy.TenantDeactivated",
"traceId": "4a7d8e1f2c..."
}
```

`detail` is prose rendered under the caller's culture - the same error reads differently for an `Accept-Language: pt-BR` client. **A client that needs to branch on a specific error must key off `code`, never off `detail`.** The dashboard does exactly this: `isTenantDeactivatedError` matches `code === "Multitenancy.TenantDeactivated"` to route the user to the terminal `/tenant-deactivated` screen, and that detection keeps working in every language.

Exceptions thrown without a `MessageKey` omit the property entirely, so its absence is meaningful: there is no stable code to branch on for that error.

## The response shape

```http
Expand Down
8 changes: 4 additions & 4 deletions src/content/docs/cross-cutting-concerns/index.mdx
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
---
title: Overview
lastUpdated: 2026-06-11
description: Platform features that span every module - caching, jobs, observability, idempotency, feature flags, rate limiting, health, realtime, SSE, HTTP resilience, and error handling.
description: Platform features that span every module - caching, jobs, observability, idempotency, feature flags, rate limiting, health, realtime, SSE, HTTP resilience, error handling, and internationalization.
sidebar:
order: 1
pageType: concept
seo:
title: 'Cross-cutting concerns in .NET 10 - fullstackhero platform'
description: 'The eleven platform-level features that wire once and apply across every module in fullstackhero - caching, jobs, observability, idempotency, feature…'
description: 'The twelve platform-level features that wire once and apply across every module in fullstackhero - caching, jobs, observability, idempotency, feature…'
keywords: 'cross-cutting concerns .net 10, platform features asp.net core, hybridcache hangfire opentelemetry, idempotency feature flags'
---

Eleven platform features span every module in fullstackhero. They wire once at the host layer, then any module consumes them through the same interfaces and conventions - no per-module bespoke setup.
Twelve platform features span every module in fullstackhero. They wire once at the host layer, then any module consumes them through the same interfaces and conventions - no per-module bespoke setup.

<Callout type="tip" title="Toggle what you need">
The kit's `FshPlatformOptions` (passed to `AddHeroPlatform`) toggle each optional sub-system. Caching, jobs, mailing, feature flags, SSE, realtime, and quotas are **off by default** - opt in to what you need. CORS, OpenAPI, OpenTelemetry, idempotency, and global exception handling are on by default because the cost is negligible.
</Callout>

## The eleven concerns
## The twelve concerns

<SectionIndex section="cross-cutting-concerns" />

Expand Down
Loading