Skip to content
Merged
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ The invariant: **application and domain code talks to abstractions; transport ch
- **Identity completions (all opt-in, ADR-116)**: `AddTwoFactorAuthentication(config)` (RFC 6238; the consumer supplies `ITwoFactorStore` and passes the resolved authenticator to `AuthenticationServiceBase`), `AddEmailConfirmation(config)` (hashed single-use tokens; sign-in gating only when `RequireConfirmedEmail` is set AND the `User` implements `IEmailConfirmableUser`), and `UsersAdminControllerBase<TUserDto>` / `RolesAdminControllerBase`. Every piece is an optional constructor argument or a separate DI call, so a consumer adopting none changes nothing.
- **Push notifications**: SignalR pipeline in Infrastructure (`NotificationHub`, `SignalRPushNotificationSender`, `NullPushNotificationSender` fallback). Native OS push (ADR-044) defaults to the inert `NullNativePushSender` / `NullPushDeviceRegistrar`; `AddAzureNotificationHubsPush(configuration)` (MMCA.Common.Azure) swaps in Notification Hubs, `AddAmazonSnsPush(configuration)` (MMCA.Common.Aws) swaps in SNS mobile push with a DynamoDB installation registry, or `AddFirebaseCloudMessagingPush(configuration)` (MMCA.Common.Gcp) swaps in FCM HTTP v1 with a Firestore installation registry (FCM registration tokens only; an APNs-platform registration fails validation).
- **Managed file storage (ADR-045)**: `IFileStorageService` (`NullFileStorageService` fallback in core; `AddAzureBlobFileStorage(configuration)` from MMCA.Common.Azure, `AddS3FileStorage(configuration)` from MMCA.Common.Aws for Amazon S3, Cloudflare R2, MinIO and the GCS XML API, or `AddGoogleCloudStorage(configuration)` from MMCA.Common.Gcp over Application Default Credentials, all binding the `FileStorage` section and keyed on different fields: `ContainerName`, `Bucket`, `GcsBucket`) plus dependency-free upload guards: `ImageContentSniffer`, `DocumentContentSniffer` (accepts only when the BYTES and the extension agree, with a zip-bomb guard on the Office Open XML check), `BlobNames.SanitizeFileName`, and `FileUploadOptions` for the RFC 6266 `Content-Disposition`. Every implementation provides the options-carrying `UploadAsync` overload (it is abstract) and honors its headers.
- **Installable web app (ADR-136)**: `app.MapCommonServiceWorker()` (UI.Web) serves `/service-worker.js` and the host loads `_content/MMCA.Common.UI.Web/pwa-register.js`; the host owns `manifest.webmanifest`, icons and `theme-color`. **The worker never caches a page or a response** (server-rendered pages carry the signed-in user's data): GET navigations go to the network and fall back to the offline page, nothing else passes through it. `Pwa:ServiceWorker:Enabled=false` serves the retired worker, the kill switch.
- **Idempotency**: `[Idempotent]`, `Idempotency-Key` header, 24h replay cache, duplicates return `X-Idempotent-Replay: true`. **The cache key is not the bare client key**: `BuildCacheKey` joins subject, method, route and client key and SHA-256s them, so two callers cannot collide. Mutual exclusion is an `IDistributedLock` from DI (`AddCaching` registers `RedisDistributedLock` when an `IConnectionMultiplexer` is present, else a warn-once in-process lock); the striped double-check path is only the fallback for a host registering no lock (ADR-017). A duplicate that cannot take the lock within `LockWait` (5s) gets **409**, not a replay. Non-2xx is never cached.
- **AI packages (optional, rubric section 16)**: `AddMmcaChatClient(configuration)` binds the `Ai` section and, only when `Ai:Enabled`, registers one `IChatClient` built outermost-first as `BoundedChatClient -> [GuardrailChatClient] -> UsageRecordingChatClient -> [DistributedCache] -> OpenTelemetry -> PromptTagging -> Logging -> provider`. **The provider is an adapter package, never a type the governed package names**: `MMCA.Common.AI.Anthropic` and `MMCA.Common.AI.OpenAI` each register one `IAiProviderFactory` (`AddAnthropicAiProvider()` / `AddOpenAiProvider()`), `Ai:Provider` is a string matched against those names and validated on start, and the `provider` metric tag comes from the client's own `ChatClientMetadata`. `BoundedChatClient` clamps output tokens, enforces a per-call timeout, pins `Ai:Model` (a request naming another model is refused), filters tools through every registered `IChatToolPolicy` (consequential tools also need the request's `mmca.tool.confirmed` stamp; `AllowTools` true with no policy throws at registration), and refuses a request over `PerCallInputTokenBudget` on an ESTIMATE. `GuardrailChatClient` runs the registered `IChatRequestRedactor`s then every `IChatGuardrail` on the request, the response and each streamed update; **`Ai:RequireGuardrail` defaults to true**, so an enabled host with no guardrail throws at registration (`AddPiiRedactionGuardrail()` and `AddContentPolicyGuardrail(configuration)` are the shipped ones; the second binds `Ai:ContentPolicy`, redacts or blocks prompt-injection markers in user-role content and refuses answers matching `BlockedResponsePatterns`). `PromptContract` carries a normalized SHA-256 `Hash`; `MMCA.Common.AI.Testing` ships the harness that keys on it (`ReplayChatClient`, `GoldenReplayTestsBase`, `PromptContractPinTestsBase`), and the AI test project runs both bases over a reference contract so the gate is exercised in this repo's own CI. **The AI packages take no MMCA.Common project reference and nothing in the framework references them** (`AiDependencyIsolationTestsBase` + `EnforceAiLayerBoundary` hold both directions: assembly names prefix-matched on `MMCA.Common.AI.`, type dependencies matched on the `MMCA.Common.AI` namespace segment, because a dotted NetArchTest namespace entry matches nothing). When `Ai:Enabled` is false nothing is registered, so consumers gate on `GetService<IChatClient>()`, not on a flag.
- **Cloud vendor adapters**: the core packages reference no Azure or AWS SDK. Vendor code lives in `MMCA.Common.Azure` (Key Vault configuration, the DataProtection key ring, the Azure Monitor exporter, Blob storage, Notification Hubs, the Service Bus transport, the Entra SQL auth provider), `MMCA.Common.Aws` (S3-compatible storage, the SQS/SNS transport, Secrets Manager configuration, the Parameter Store DataProtection key ring, SNS native push over a DynamoDB installation registry, RDS IAM authentication for PostgreSQL), `MMCA.Common.Gcp` (Google Cloud Storage, Secret Manager configuration, the Cloud Storage DataProtection key ring with optional Cloud KMS, FCM native push over a Firestore installation registry, Cloud SQL IAM authentication for PostgreSQL, the Telemetry (OTLP) API exporter for traces and logs) and `MMCA.Common.Testing.Azure` (the Service Bus emulator fixture), each capability one registration call; adapters reference the core, never the other way round (`CloudVendorIsolationTestsBase` + `EnforceCloudVendorBoundary` hold both directions, matched on package id). Exactly two references are exempt, both database engines rather than cloud services: `Microsoft.EntityFrameworkCore.Cosmos` in Infrastructure (the Cosmos engine; its `Microsoft.Azure.Cosmos` namespace is allowed there alone) and `Aspire.Hosting.Azure.CosmosDB` in Aspire.Hosting (the AppHost-side emulator). **Configuration that asks for a vendor capability without its adapter call fails startup, naming the call**: `AddInfrastructure` checks `NativePush:Enabled` (never `FileStorage`: a shared section declares no intent, and the null store already fails each upload with `FileStorage.NotConfigured`), `AddServiceDefaults()` checks `APPLICATIONINSIGHTS_CONNECTION_STRING`, `KeyVault:Uri`, `DataProtection:BlobStorageUri`, `SecretsManager:NamePrefix`, `DataProtection:SsmParameterPrefix`, `GoogleSecretManager:NamePrefix`, `DataProtection:GcsObjectUri` and `GoogleCloudTelemetry:ProjectId` (and fails when more than one key-ring gate is set). PostgreSQL connection customization (a password provider, for example) goes through `AddNpgsqlDataSourceConfigurator`, which makes the context and the health check share one `NpgsqlDataSource` per connection string.
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ All notable changes to the MMCA.Common packages are documented here. The format
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [Semantic Versioning](https://semver.org/)
and are derived from git tags by MinVer (see [the published versioning policy](https://ivanball.github.io/docs/guides/common-VERSIONING.html)).

## [1.264.0] - 2026-10-27

### Added

- Added: installable web app support for Blazor Web hosts (MMCA.Common.UI.Web, [ADR-136](https://ivanball.github.io/docs/adr/136-installable-web-app-service-worker.html)). `app.MapCommonServiceWorker()` serves `/service-worker.js` at the site root, anonymous, uncached and stamped with the package version so each release updates it. The worker handles GET navigations only: it sends every one to the network (with navigation preload) and shows the framework offline page (`_content/MMCA.Common.UI.Web/offline.html`) when the network fails. It never caches a page or a response, because a server-rendered page carries the signed-in user's data; API calls, the Blazor circuit, hubs and framework files never pass through it. `_content/MMCA.Common.UI.Web/pwa-register.js` registers it after the page `load` event, in a secure context only (a file, because the host CSP blocks inline script). `Pwa:ServiceWorker:Enabled=false` is the kill switch: it serves a worker with no fetch handler that deletes the offline cache, which every installed client takes on its next navigation. The host supplies the app identity itself (`manifest.webmanifest`, icons, `theme-color`); the existing CSP already allows a same-origin manifest and worker, and the UI rate limiter already exempts both paths. A host that maps nothing changes nothing.

### Dependencies

- `Polly` and `Polly.Core` 8.8.0 -> 8.8.1, `Google.Cloud.Firestore` 4.5.0 -> 4.6.0, and patch updates of `AWSSDK.DynamoDBv2`, `AWSSDK.RDS`, `AWSSDK.S3`, `AWSSDK.SecretsManager` and `AWSSDK.SimpleNotificationService` (Dependabot #620). Every lock file regenerated, the out-of-slnx projects included.

## [1.263.0] - 2026-10-27

### Added
Expand Down
16 changes: 8 additions & 8 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -153,28 +153,28 @@
<!-- S3-compatible file storage (MMCA.Common.Aws.AddS3FileStorage): AWS S3, Cloudflare R2, MinIO
and the GCS XML API through one client. The 4.x line sends request checksums by default,
which the adapter turns down to WHEN_REQUIRED for the S3-compatible services. -->
<PackageVersion Include="AWSSDK.S3" Version="4.0.104.6" />
<PackageVersion Include="AWSSDK.S3" Version="4.0.104.7" />
<!-- Secrets Manager configuration source (MMCA.Common.Aws.AddAwsSecretsManagerConfiguration):
a hand-written provider that lists the secrets under one name prefix and reads them in
batches. Same AWS SDK v4 line as AWSSDK.S3, so both share one AWSSDK.Core. -->
<PackageVersion Include="AWSSDK.SecretsManager" Version="4.0.100.21" />
<PackageVersion Include="AWSSDK.SecretsManager" Version="4.0.100.22" />
<!-- Shared DataProtection key ring (MMCA.Common.Aws.AddAwsDataProtection): each key is one
SecureString parameter in Systems Manager, KMS-encrypted at rest. AWS-authored; brings
AWSSDK.SimpleSystemsManagement and AWSSDK.Extensions.NETCore.Setup on the same v4 line. -->
<PackageVersion Include="Amazon.AspNetCore.DataProtection.SSM" Version="4.0.3" />
<!-- RDS IAM database authentication for PostgreSQL (MMCA.Common.Aws.AddAwsRdsIamAuthentication):
only RDSAuthTokenGenerator is used, which signs the token locally with SigV4. Same AWS SDK
v4 line as AWSSDK.S3. -->
<PackageVersion Include="AWSSDK.RDS" Version="4.0.107.1" />
<PackageVersion Include="AWSSDK.RDS" Version="4.0.107.2" />
<!-- SNS mobile push (MMCA.Common.Aws.AddAmazonSnsPush): platform endpoints, the broadcast topic
and per-endpoint publishes. Also a transitive dependency of MassTransit.AmazonSQS (which
floors it at 4.0.100.15); the direct pin must stay at or above that floor so both paths
resolve one assembly. Same AWS SDK v4 line as AWSSDK.S3. -->
<PackageVersion Include="AWSSDK.SimpleNotificationService" Version="4.0.100.21" />
<PackageVersion Include="AWSSDK.SimpleNotificationService" Version="4.0.100.22" />
<!-- The native push installation registry (MMCA.Common.Aws.AddAmazonSnsPush): one DynamoDB item
per installation, a conditional put for the ownership rule and a UserId index for
user-targeted sends. Low-level client only. Same AWS SDK v4 line as AWSSDK.S3. -->
<PackageVersion Include="AWSSDK.DynamoDBv2" Version="4.0.109.1" />
<PackageVersion Include="AWSSDK.DynamoDBv2" Version="4.0.109.2" />
<!-- Google Cloud Storage file storage (MMCA.Common.Gcp.AddGoogleCloudStorage): the JSON API
client over Application Default Credentials (workload identity, no HMAC keys). Brings the
Google.Apis.* REST stack and Google.Api.Gax.Rest; no gRPC. Apache-2.0. -->
Expand Down Expand Up @@ -202,7 +202,7 @@
<!-- The FCM installation registry (MMCA.Common.Gcp.AddFirebaseCloudMessagingPush): one Firestore
document per installation, a transaction for the ownership rule and a UserId equality query
for user-targeted sends. gRPC over Google.Api.Gax.Grpc, the Secret Manager line; Apache-2.0. -->
<PackageVersion Include="Google.Cloud.Firestore" Version="4.5.0" />
<PackageVersion Include="Google.Cloud.Firestore" Version="4.6.0" />
<!-- Shared DataProtection key ring (MMCA.Common.Azure.AddAzureDataProtection). Blobs persists
the key ring so every replica decrypts the same auth cookies and antiforgery tokens; Keys is
the optional at-rest encryption of that key ring. Both reuse the Azure.Identity pin above. -->
Expand Down Expand Up @@ -237,13 +237,13 @@
<PackageVersion Include="Microsoft.Extensions.Localization" Version="10.0.14" />
<PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.14" />
<PackageVersion Include="MudBlazor" Version="9.11.0" />
<PackageVersion Include="Polly" Version="8.8.0" />
<PackageVersion Include="Polly" Version="8.8.1" />
<!-- Circuit breaker on the outbox broker-publish path (MMCA.Common.Infrastructure). Polly.Core
rather than the Polly meta-package: Core carries the v8 resilience-pipeline API on its own,
while Polly adds the v7 Policy compatibility shim that Infrastructure has no use for. Kept
on the same version as the Polly pin above so the two never resolve to different Core
assemblies in one graph. -->
<PackageVersion Include="Polly.Core" Version="8.8.0" />
<PackageVersion Include="Polly.Core" Version="8.8.1" />
<!-- QR encoding for the shared QrCodeImage component (MIT). Pure managed encode-to-PNG: no
native binary, no network call, no image service, so it renders identically on every head
including Blazor WebAssembly. -->
Expand Down
10 changes: 8 additions & 2 deletions Source/Core/MMCA.Common.Application/PublicAPI.Shipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -498,8 +498,6 @@ MMCA.Common.Application.Interfaces.Infrastructure.Storage.IFileStorageService.De
MMCA.Common.Application.Interfaces.Infrastructure.Storage.IFileStorageService.IsConfigured.get -> bool
MMCA.Common.Application.Interfaces.Infrastructure.Storage.IFileStorageService.UploadAsync(string! blobName, System.IO.Stream! content, string! contentType, MMCA.Common.Application.Interfaces.Infrastructure.Storage.FileUploadOptions! options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<MMCA.Common.Shared.Abstractions.Result<System.Uri!>!>!
MMCA.Common.Application.Interfaces.Infrastructure.Storage.IFileStorageService.UploadAsync(string! blobName, System.IO.Stream! content, string! contentType, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<MMCA.Common.Shared.Abstractions.Result<System.Uri!>!>!
MMCA.Common.Application.Interfaces.Infrastructure.Storage.IImageProcessor
MMCA.Common.Application.Interfaces.Infrastructure.Storage.IImageProcessor.NormalizeToSquareJpegAsync(System.IO.Stream! content, int size, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<MMCA.Common.Shared.Abstractions.Result<byte[]!>!>!
MMCA.Common.Application.Interfaces.Infrastructure.Storage.ImageContentSniffer
MMCA.Common.Application.Interfaces.Mapping.ICreateRequest
MMCA.Common.Application.Interfaces.Mapping.IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>
Expand Down Expand Up @@ -1102,6 +1100,14 @@ MMCA.Common.Application.Validation.StateRules<T>
MMCA.Common.Application.Validation.StateRules<T>.StateRules(System.Linq.Expressions.Expression<System.Func<T, string?>!>! selector) -> void
MMCA.Common.Application.Validation.StrongPasswordRules<T>
MMCA.Common.Application.Validation.StrongPasswordRules<T>.StrongPasswordRules(System.Linq.Expressions.Expression<System.Func<T, string!>!>! selector, string? errorCode = null) -> void
MMCA.Common.Application.Validation.UsAddressValidator
MMCA.Common.Application.Validation.UsAddressValidator.UsAddressValidator() -> void
MMCA.Common.Application.Validation.UsCityRules<T>
MMCA.Common.Application.Validation.UsCityRules<T>.UsCityRules(System.Linq.Expressions.Expression<System.Func<T, string?>!>! selector) -> void
MMCA.Common.Application.Validation.UsStateRules<T>
MMCA.Common.Application.Validation.UsStateRules<T>.UsStateRules(System.Linq.Expressions.Expression<System.Func<T, string?>!>! selector) -> void
MMCA.Common.Application.Validation.UsZipCodeRules<T>
MMCA.Common.Application.Validation.UsZipCodeRules<T>.UsZipCodeRules(System.Linq.Expressions.Expression<System.Func<T, string?>!>! selector) -> void
MMCA.Common.Application.Validation.ZipCodeRules<T>
MMCA.Common.Application.Validation.ZipCodeRules<T>.ZipCodeRules(System.Linq.Expressions.Expression<System.Func<T, string?>!>! selector) -> void
abstract MMCA.Common.Application.Auth.AuthenticationServiceBase<TUser>.CreateAccessToken(TUser! user) -> string!
Expand Down
10 changes: 0 additions & 10 deletions Source/Core/MMCA.Common.Application/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,11 +1 @@
#nullable enable
*REMOVED*MMCA.Common.Application.Interfaces.Infrastructure.Storage.IImageProcessor
*REMOVED*MMCA.Common.Application.Interfaces.Infrastructure.Storage.IImageProcessor.NormalizeToSquareJpegAsync(System.IO.Stream! content, int size, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<MMCA.Common.Shared.Abstractions.Result<byte[]!>!>!
MMCA.Common.Application.Validation.UsAddressValidator
MMCA.Common.Application.Validation.UsAddressValidator.UsAddressValidator() -> void
MMCA.Common.Application.Validation.UsCityRules<T>
MMCA.Common.Application.Validation.UsCityRules<T>.UsCityRules(System.Linq.Expressions.Expression<System.Func<T, string?>!>! selector) -> void
MMCA.Common.Application.Validation.UsStateRules<T>
MMCA.Common.Application.Validation.UsStateRules<T>.UsStateRules(System.Linq.Expressions.Expression<System.Func<T, string?>!>! selector) -> void
MMCA.Common.Application.Validation.UsZipCodeRules<T>
MMCA.Common.Application.Validation.UsZipCodeRules<T>.UsZipCodeRules(System.Linq.Expressions.Expression<System.Func<T, string?>!>! selector) -> void
Loading
Loading