Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c498afc
feat(localization): request localization, localized errors and user l…
marcelo-maciel Aug 17, 2026
7962ea4
feat(localization): localize module messages and validators
marcelo-maciel Aug 17, 2026
28c3aaa
fix(localization): resolve exception bodies under the negotiated requ…
marcelo-maciel Sep 8, 2026
92c62f7
build(deps): bump Testcontainers to 4.14.0 and SourceLink past their …
marcelo-maciel Sep 14, 2026
1762333
build(deps): bump Testcontainers to 4.14.0 and SourceLink past their …
marcelo-maciel Sep 14, 2026
faf8165
fix(infra): pull MinIO from quay.io on a pinned tag, not Docker Hub
marcelo-maciel Sep 14, 2026
560111e
fix(infra): pull MinIO from quay.io on a pinned tag, not Docker Hub
marcelo-maciel Sep 14, 2026
68ba4a9
fix(identity): treat a blank profile locale as absent, not as a value
marcelo-maciel Sep 17, 2026
9ee0dcb
Merge remote-tracking branch 'fork/feat/i18n-framework' into pr1382
marcelo-maciel Sep 18, 2026
8dd8121
test(identity): pin the UI culture so validator assertions stop depen…
marcelo-maciel Sep 18, 2026
06abc19
test(localization): pin the UI culture in the three remaining assemblies
marcelo-maciel Sep 18, 2026
a519a26
fix(migrations): order AddUserLocale after the migrations already on …
marcelo-maciel Sep 18, 2026
9fdc469
test(framework): gate placeholder parity between the resx catalogs
marcelo-maciel Sep 18, 2026
c0a2244
refactor(framework): freeze the supported-culture whitelist
marcelo-maciel Sep 18, 2026
f18b072
feat(identity): localize the 401 challenge body
marcelo-maciel Sep 18, 2026
b3819d5
Merge remote-tracking branch 'fork/feat/i18n-framework' into pr1382
marcelo-maciel Sep 18, 2026
3a6b9ad
fix(localization): translate the enum arguments inside localized mess…
marcelo-maciel Sep 18, 2026
182d022
test(architecture): pin the premise Audit.RealExceptionType depends on
marcelo-maciel Sep 18, 2026
d86967d
fix(infra): pull minio/mc from quay.io too, not just minio/minio
marcelo-maciel Sep 18, 2026
88c0052
build(deps): drop the dead SSH.NET pin
marcelo-maciel Sep 18, 2026
a4dfa82
ci: run Tickets.Tests in the unit-test job
marcelo-maciel Sep 18, 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
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.
2 changes: 1 addition & 1 deletion .github/workflows/backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ jobs:

- name: Run unit tests with coverage
run: |
for proj in Architecture Auditing Caching Generic Identity Multitenancy Billing Catalog Chat Files Framework Webhooks; do
for proj in Architecture Auditing Caching Generic Identity Multitenancy Billing Catalog Chat Files Framework Tickets Webhooks; do
echo "::group::${proj}.Tests"
dotnet test "src/Tests/${proj}.Tests" -c Release --no-build \
--collect:"XPlat Code Coverage" --settings coverage.runsettings \
Expand Down
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
6 changes: 3 additions & 3 deletions deploy/docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ This brings up the full stack on a single host:
| `admin` | `fsh/admin:local` | `FSH_ADMIN_PORT` (default 8081) | Operator console (nginx + React) |
| `dashboard` | `fsh/dashboard:local` | `FSH_DASHBOARD_PORT` (default 8082) | Tenant dashboard (nginx + React) |
| `migrator` | `fsh/dbmigrator:local` | — | One-shot: applies EF migrations + seeds the root tenant + creates the default admin user |
| `postgres` | `postgres:17-alpine` | (internal) | Identity, tenant catalog, module schemas |
| `redis` | `redis:7-alpine` | (internal) | HybridCache L2, Data Protection keys, idempotency store |
| `minio` | `minio/minio:latest` | (internal) | S3-compatible blob store for the Files module |
| `postgres` | `postgres:18-alpine` | (internal) | Identity, tenant catalog, module schemas |
| `redis` | `valkey/valkey:9.1.0-alpine` | (internal) | HybridCache L2, Data Protection keys, idempotency store |
| `minio` | `quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z` | (internal) | S3-compatible blob store for the Files module |

The compose file does **not** include a reverse proxy or TLS terminator. You bring your own edge — Cloudflare Tunnel, AWS ALB, Tailscale Funnel, your existing nginx, anything that can route a TLS subdomain to a host:port on this machine.

Expand Down
6 changes: 4 additions & 2 deletions deploy/docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ services:
# - "6379:6379"

minio:
image: minio/minio:latest
# quay.io: minio/minio is gone from Docker Hub. Tag pinned; quay stopped moving :latest.
image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z
container_name: fsh-minio
restart: unless-stopped
command: ["server", "/data", "--console-address", ":9001"]
Expand All @@ -79,7 +80,8 @@ services:
# policy is set — objects are served via the API / presigned URLs, not a
# public bucket.
minio-init:
image: minio/mc:latest
# quay.io: minio/mc is gone from Docker Hub too. Tag pinned; quay stopped moving :latest.
image: quay.io/minio/mc:RELEASE.2025-08-13T08-35-41Z
container_name: fsh-minio-init
restart: "no"
depends_on:
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