Skip to content
Open
4 changes: 4 additions & 0 deletions src/content/docs/changelog/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ The transactional outbox was rebuilt so that any module can publish, every tenan

- **Mailing: e-mails now carry real HTML plus a plain-text alternative, so the password-reset link is clickable again (fix).** Every provider puts `MailRequest.Body` in the **HTML** part (MailKit's `BodyBuilder.HtmlBody`, SendGrid's `htmlContent`), but the password-reset and welcome mails passed bare text into it. A plain URL inside an HTML part is not auto-linked by most clients - auto-linking is `text/plain` behaviour - so **the reset link arrived as dead text** and the user could not finish the flow. The welcome mail also interpolated the user-supplied first name straight into that markup, and SendGrid was handed `Body` as *both* parts, shipping raw markup to text-only clients. `MailRequest` gains an optional **`TextBody`** for the `text/plain` alternative: `SmtpMailService` emits both parts as multipart/alternative, and `SendGridMailService` maps them separately onto `plainTextContent`/`htmlContent`. Identity builds its bodies through a new `EmailBodies` helper that renders the action link as a real `<a href>` and HTML-encodes every interpolated value; the four tenant billing mails gained their plain twin, so no message goes out HTML-only. **Action for deployments:** none - `TextBody` is optional and appended last, so existing callers keep compiling. If you send mail from your own code, put HTML in `Body` and the plain wording in `TextBody`; a bare URL in `Body` will not be clickable. See [#1351](https://github.com/fullstackhero/dotnet-starter-kit/pull/1351).

## 2026-07-20

- **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 `MessageKey` / `ResourceSource` 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. See [Internationalization](/docs/cross-cutting-concerns/internationalization/) and [Error handling](/docs/cross-cutting-concerns/error-handling/#code---the-machine-readable-discriminator).

## 2026-07-11

A security & reliability audit pass across the backend. Every finding was reproduced with a failing test and adversarially verified before fixing; the suite stays green (warnings-as-errors, Testcontainers integration tests).
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