Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 3 additions & 2 deletions .agents/rules/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,9 @@ In `src/BuildingBlocks/Web/Extensions.cs` (`UseHeroPlatform`):
2. **CORS before HTTPS redirect** (so OPTIONS preflight isn't 307-redirected)
3. HttpsRedirection → SecurityHeaders → static files → Routing
4. **`UseAuthentication`**
5. **`UseModuleMiddlewares`** — each module's `ConfigureMiddleware`, runs **after** auth
6. RateLimiting → Quotas → `UseAuthorization` → `MapModules`
5. **`UseHeroLocalization`** — request localization, sits **between `UseAuthentication` and `UseAuthorization`** so the user-`locale`-claim culture provider can read `HttpContext.User`
6. **`UseModuleMiddlewares`** — each module's `ConfigureMiddleware`, runs **after** auth
7. RateLimiting → Quotas → `UseAuthorization` → `MapModules`

`app.UseHeroMultiTenantDatabases()` (Finbuckle `UseMultiTenant()`) runs in `Program.cs` **before** `UseHeroPlatform`, i.e. **before `UseAuthentication`** — so tenant resolution is header-driven, not claim-driven. See `modules/multitenancy.md`.

Expand Down
77 changes: 77 additions & 0 deletions .agents/rules/localization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Localization (i18n)

`src/BuildingBlocks/Core/Localization/` + per-module `Localization/` folders. Read before adding any user-facing message (exception, validation, API error). **The client's culture decides which words the client reads; it never decides how the API formats numbers or dates.**

## Culture negotiation (already wired — don't re-add)

`AddHeroLocalization()` / `UseHeroLocalization()` (`BuildingBlocks/Web/Localization/`) negotiate the request **UI** culture in this order: `?culture=` query → `locale` JWT claim (`UserLocaleRequestCultureProvider`) → `Accept-Language` → configured default → `en-US`. Supported tags live in `SupportedCultures.Tags`. The culture is set before endpoints and the exception handler run, so any `IStringLocalizer` resolved downstream picks up the request culture automatically.

**`CultureInfo.CurrentUICulture` only — `CurrentCulture` stays invariant.** An API that emits JSON must not shift `ToString()`, `Parse()` or interpolation per request; both React apps format at the presentation layer. `RequestLocalizationMiddleware` assigns both cultures unconditionally, so the culture half is pinned rather than left alone: `DefaultRequestCulture` carries `(InvariantCulture, configured default)` and `SupportedCultures` is `null` so the middleware skips culture filtering. Do **not** "fix" this by adding `AddSupportedCultures(...)`; `Formatting_culture_stays_invariant_while_ui_culture_negotiates` fails if you do.

`SupportedCultures.Tags` is **specific tags only**, no neutrals. A request asking for a bare `pt`, or for an unsupported variant like `pt-PT`, resolves to the configured default rather than being served a language it was not translated into. The React apps canonicalize variants onto supported tags (`CANON` in `clients/*/src/i18n.ts`) before calling the API, so app traffic is unaffected; a hand-rolled client sending bare `pt` gets the default. Adding a language means: add its specific tag to `Tags`, add a `*.{tag}.resx` per catalog, add its JSON catalogs to both apps, and remove it from `CANON` if it was being folded into another tag.

## Catalogs — hybrid, one marker per catalog

- **Core (`SharedResources`)** — generic / cross-cutting messages: ProblemDetails titles (`Error.*`), cross-module errors (`Error.TenantContextRequired`, `Error.NoCurrentUser`, …), and shared validation (`Validation.*`).
- **Per module (`<Module>Resources`)** — domain-specific messages owned by the module: `src/Modules/<Module>/Modules.<Module>/Localization/<Module>Resources.cs` (marker `public sealed class <Module>Resources;`) + co-located `<Module>Resources.resx` (neutral / en-US) + `<Module>Resources.pt-BR.resx`. `ResourcesPath = ""` (co-located), so the resx manifest name must equal the marker's full type name.

Catalogs are named for **specific** cultures (`.pt-BR`, never a neutral `.pt`), matching the front-end catalog folders. The neutral, un-suffixed `.resx` is the en-US / ultimate-fallback catalog.

Key naming: `Error.<Module>.<Case>` for domain messages (`Catalog.ProductNotFound`), `Error.<CrossCutting>` / `Validation.<Case>` for Core. PascalCase. Placeholders are `{0}`, `{1}` (`string.Format` via the localizer) — **not** the frontend's `{{name}}`.

**Placeholder arguments must be culture-insensitive.** The localizer's indexer calls `string.Format` under `CurrentCulture`, which is invariant (above). Pass `int`/`long`/`string`/enum — never a `double`, `decimal`, `DateTime` or `TimeSpan.TotalX`, which would render with an invariant separator instead of the reader's. Where a count is conceptually whole, expose it as an `int` at the source rather than converting at the call site (see `GetAuditsQueryHandler.MaxWindowDays` next to `MaxWindow`). Money and dates belong in structured response fields formatted by the client, not interpolated into a message.

## Exceptions — localize at the boundary, log stays English

Throw with the **English message** as `Exception.Message` (used for logs and fallback) plus the resource key metadata. **Never** pre-localize the message at the throw site.

```csharp
// domain message -> module catalog
throw new NotFoundException($"Product {id} not found.")
{
MessageKey = "Catalog.ProductNotFound",
MessageArgs = [id],
ResourceSource = typeof(CatalogResources),
};

// cross-cutting message -> Core catalog (ResourceSource omitted = SharedResources)
throw new UnauthorizedException("Tenant context is required.")
{
MessageKey = "Error.TenantContextRequired",
};
```

`GlobalExceptionHandler` resolves `Title` (by status) and `Detail` (via `MessageKey` + `ResourceSource`) under the request culture, and falls back to `Exception.Message` when the key is missing (`ResourceNotFound`) or malformed (`FormatException`). Migration is therefore incremental: an un-migrated `throw new NotFoundException("...")` still renders its English literal.

**Do NOT** set `ProblemDetails` from a localized string in logs — the handler logs `Exception.Message` (English) and the type name, never the translated body.

## Validators — inject the localizer, defer resolution

```csharp
public sealed class XCommandValidator : AbstractValidator<XCommand>
{
public XCommandValidator(IStringLocalizer<SharedResources> localizer)
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage(_ => localizer["Validation.NameRequired"]);
}
}
```

Always the `.WithMessage(_ => localizer["Key"])` lambda (resolution is deferred to `Validate()`, under the request culture) — never `.WithMessage(localizer["Key"])`. **Catalog choice:** inject `IStringLocalizer<SharedResources>` for genuinely shared/generic validation (`Validation.*` already in Core, reuse them), or `IStringLocalizer<<Module>Resources>` for module-specific validation messages kept in the module's own catalog. DI provides the localizer automatically (`AddValidatorsFromAssembly` + `AddHeroLocalization` + the module's own `AddLocalization`); nested validators (`Include(new PagedQueryValidator<T>(localizer))`) receive it from the parent.

## Known behaviour (documented, not bugs)

- **The `locale` claim lags a language switch by one token.** The culture provider reads the JWT `locale` claim, so a switch does not reach the API until the next token issue. The front-end persists the choice to the profile and re-mints, so it converges; in the window between, the shell can be in the new language while an API error still comes back in the old one. Deliberate: the alternative is a per-request DB read on every authenticated call.
- **Impersonation carries the operator's language, not the target's.** `StartImpersonationCommandHandler` strips the target's `locale` claim so the operator keeps reading in their own language, and the cross-app handoff URL carries `locale` because the dashboard is normally on a different origin and cannot read admin's `i18nextLng`. During impersonation the switcher is client-side only — it must not PUT onto the impersonated user's profile.
- **SignalR does not carry the app locale.** The hub client builds its own requests instead of going through `apiFetch`, so `Accept-Language` on the negotiate is the browser's. Applies to every session. `handoff-locale.spec.ts` names the exception explicitly so any *other* channel that stops carrying the locale fails the test.

## Tests (required with every catalog change)

- **Parity** — every key present in both the neutral and the `pt-BR` catalog, for Core and every `<Module>Resources`. Per-catalog tests live in each module's test project; `CatalogParityTests` in `Architecture.Tests` enumerates every module catalog generically, so a **new** module catalog is covered without adding a test.
- **Code → resx guard** — every referenced key (`MessageKey`, `localizer["…"]`) must exist in its catalog, or the build fails. This is what catches a forgotten/typo `ResourceSource` (which would otherwise fall back silently).
- Build validators/handlers with a real localizer from the embedded catalog via `SharedResourcesLocalizerFactory.Create()` (test-project `Support/` helper), not a stub.

## Emails / background handlers

Integration-event handlers run without an HTTP request, so there is no negotiated culture. Localizing outbound emails needs the recipient's stored locale propagated to the handler — **not yet implemented** (tracked for a future PR); email bodies stay English for now.
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ Single long-lived branch: **`main`** (the default) — there is **no `develop`**
| CORS, security headers, rate limiting, idempotency, quotas | `security.md` |
| SignalR / SSE backend | `realtime.md` |
| Logging, correlation, OpenTelemetry | `logging.md` |
| Localization (i18n), request culture, resource catalogs, localized exceptions | `localization.md` |
| Unit test conventions, NetArchTest | `testing.md` |
| Integration tests (Testcontainers harness + gotchas) | `integration-testing.md` |
| **Modifying `src/BuildingBlocks`** (read first — it's protected) | `buildingblocks-protection.md` |
Expand Down
2 changes: 2 additions & 0 deletions src/BuildingBlocks/Core/Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
<PropertyGroup>
<RootNamespace>FSH.Framework.Core</RootNamespace>
<AssemblyName>FSH.Framework.Core</AssemblyName>
<!-- SharedResources is an intentional empty localizer marker (see Localization/SharedResources.cs). -->
<NoWarn>$(NoWarn);S2094</NoWarn>
</PropertyGroup>

<ItemGroup>
Expand Down
20 changes: 19 additions & 1 deletion src/BuildingBlocks/Core/Exceptions/CustomException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace FSH.Framework.Core.Exceptions;
/// FullStackHero exception used for consistent error handling across the stack.
/// Includes HTTP status codes and optional detailed error messages.
/// </summary>
public class CustomException : Exception
public class CustomException : Exception, ILocalizableMessage
{
/// <summary>
/// A list of error messages (e.g., validation errors, business rules).
Expand All @@ -19,6 +19,24 @@ public class CustomException : Exception
/// </summary>
public HttpStatusCode StatusCode { get; }

/// <summary>
/// Optional resource key resolved against <see cref="ResourceSource"/> to localize the
/// response body under the request culture. When null, the literal <see cref="Exception.Message"/>
/// is used. The message itself always stays the (English) fallback for logs.
/// </summary>
public string? MessageKey { get; init; }

/// <summary>
/// Format arguments applied to the localized message ({0}, {1}, …).
/// </summary>
public IReadOnlyList<object> MessageArgs { get; init; } = [];

/// <summary>
/// Marker type identifying the resource catalog for <see cref="MessageKey"/>. When null,
/// the shared (Core) catalog is used; module-specific keys point it at the module's own catalog.
/// </summary>
public Type? ResourceSource { get; init; }

/// <summary>
/// Initializes a new instance of the <see cref="CustomException"/> class with default message and internal server error status.
/// </summary>
Expand Down
1 change: 1 addition & 0 deletions src/BuildingBlocks/Core/Exceptions/ForbiddenException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public class ForbiddenException : CustomException
public ForbiddenException()
: base("Unauthorized access.", Array.Empty<string>(), HttpStatusCode.Forbidden)
{
MessageKey = "Error.ForbiddenAccess";
}

/// <summary>
Expand Down
20 changes: 20 additions & 0 deletions src/BuildingBlocks/Core/Exceptions/ILocalizableMessage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace FSH.Framework.Core.Exceptions;

/// <summary>
/// Implemented by exceptions whose response Detail can be localized from a resource key.
/// Lets <c>GlobalExceptionHandler</c> translate the body under the request culture while the
/// exception type stays intact — needed for BCL types the audit severity classifier keys off
/// (e.g. <see cref="UnauthorizedAccessException"/>, <see cref="KeyNotFoundException"/>).
/// The <see cref="Exception.Message"/> stays the English fallback for logs and unresolved keys.
/// </summary>
public interface ILocalizableMessage
{
/// <summary>Resource key resolved against <see cref="ResourceSource"/>; null keeps the literal message.</summary>
string? MessageKey { get; }

/// <summary>Format arguments applied to the localized message ({0}, {1}, …).</summary>
IReadOnlyList<object> MessageArgs { get; }

/// <summary>Marker type identifying the resource catalog; null uses the shared (Core) catalog.</summary>
Type? ResourceSource { get; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace FSH.Framework.Core.Exceptions;

/// <summary>
/// <see cref="KeyNotFoundException"/> whose 404 response Detail is localized via <see cref="MessageKey"/>.
/// Subclasses the BCL type on purpose so audit exception-type fixtures and severity classification that
/// key off <see cref="KeyNotFoundException"/> keep working, while the body still translates under the
/// request culture. The base message stays the English log fallback.
/// </summary>
public sealed class LocalizedKeyNotFoundException : KeyNotFoundException, ILocalizableMessage
{
public string? MessageKey { get; init; }
public IReadOnlyList<object> MessageArgs { get; init; } = [];
public Type? ResourceSource { get; init; }

public LocalizedKeyNotFoundException()
{
}

public LocalizedKeyNotFoundException(string message)
: base(message)
{
}

public LocalizedKeyNotFoundException(string message, Exception innerException)
: base(message, innerException)
{
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace FSH.Framework.Core.Exceptions;

/// <summary>
/// <see cref="UnauthorizedAccessException"/> whose 401 response Detail is localized via
/// <see cref="MessageKey"/>. Subclasses the BCL type on purpose so the audit severity classifier
/// (which maps <see cref="UnauthorizedAccessException"/> to Warning) keeps working, while the body
/// still translates under the request culture. The base message stays the English log fallback.
/// </summary>
public sealed class LocalizedUnauthorizedAccessException : UnauthorizedAccessException, ILocalizableMessage
{
public string? MessageKey { get; init; }
public IReadOnlyList<object> MessageArgs { get; init; } = [];
public Type? ResourceSource { get; init; }

public LocalizedUnauthorizedAccessException()
{
}

public LocalizedUnauthorizedAccessException(string message)
: base(message)
{
}

public LocalizedUnauthorizedAccessException(string message, Exception innerException)
: base(message, innerException)
{
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public class UnauthorizedException : CustomException
public UnauthorizedException()
: base("Authentication failed.", Array.Empty<string>(), HttpStatusCode.Unauthorized)
{
MessageKey = "Error.AuthenticationFailed";
}

/// <summary>
Expand Down
4 changes: 4 additions & 0 deletions src/BuildingBlocks/Core/Localization/SharedResources.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
namespace FSH.Framework.Core.Localization;

/// <summary>Marker type binding <c>IStringLocalizer&lt;SharedResources&gt;</c> to the shared resx catalog.</summary>
public sealed class SharedResources;
Loading
Loading