Repository navigation
Releases
v1.290.0
The 2026-11-13 NuGet audit plus one new architecture fitness base. No breaking change.
Added
CrossModuleReferenceTestsBase(MMCA.Common.Testing.Architecture) andArchitectureRules.CrossModuleReferencesMatchAllowlist. Reads every.csprojunder the repo'sSource/Modules(ModulesFolder, overridable) and pins eachProjectReferencefrom one module's project to ANOTHER module's project, in both directions, against the subclass'sAllowedCrossModuleReferences(entries written"{Module}.{Layer} -> {Module}.{Layer}"): an undeclared reference fails, and so does an entry that matches no reference. It sees couplings the IL-based isolation rules cannot, such as an inlinedconstor aglobal usingalias. The modules found on disk must equal the architecture map's modules, the map must declare at least one, and a module project outside{RepoToken}.{Module}.{Layer}is reported, so the fact cannot pass vacuously. A single-module app states an empty allowlist.
Changed
- Dependencies. OpenTelemetry 1.21.0 (Api, OTLP exporter, Extensions.Hosting, Instrumentation.AspNetCore, Http and Runtime, Exporter.InMemory); AWSSDK 4.1.x (Core, DynamoDBv2, RDS, S3, SecretsManager, SNS); Microsoft.Data.SqlClient and Microsoft.Data.SqlClient.Extensions.Azure 7.1.2 (move them together); MudBlazor 9.13.0; StackExchange.Redis 3.4.35; Anthropic 13.8.0; Scalar.AspNetCore 2.21.0; Fido2 4.3.3; FirebaseAdmin 3.7.1; bunit 2.15.2 with AngleSharp 1.10.2.
- Per-framework versions for the dotnet/extensions train and the OAuth providers. Microsoft.Extensions.Http.Resilience, ServiceDiscovery, Caching.Hybrid, AI, AI.OpenAI and TimeProvider.Testing resolve 10.11.0 on
net10.0and 11.0.0 onnet11.0; AspNet.Security.OAuth.Apple and AspNet.Security.OAuth.GitHub resolve 10.1.0 and 11.0.0. The 11.x extensions assets would floor anet10.0consumer'sMicrosoft.Extensions.*runtime packages at 11.0.0, and OAuth 11.x shipsnet11.0assets only, so the .NET 10 line stays on 10.x. - Analyzers (build-time only, nothing flows to a consumer). Meziantou.Analyzer 3.0.339, SonarAnalyzer.CSharp 10.36.0.15337 and StyleCop.Analyzers 1.3.180 (a trial of the first stable 1.3 build), with the shared
.editorconfigbaseline tightened where a full build measured zero violations and every catch-all documented with aCA1031pragma stating its reason.
v1.289.0
Breaking: two unused components are removed from MMCA.Common.UI. PageStateScope (MMCA.Common.UI.Components.PageState) and the registerBfcacheHandler/unregisterBfcacheHandler functions of nav-interop.js are removed with no replacement; no consumer used them. MobileCardList<TItem> (MMCA.Common.UI.Components.Lists) is removed; use MobileInfiniteScrollList<TItem>. ClickableCard is unchanged. See UPGRADING.md.
Breaking: MMCA.Common.Application.Auditing.AuditTrailEntryDTO is moved and renamed to MMCA.Common.Shared.Auditing.AuditHistoryEntryDTO (same members, same required set), and IAuditTrailReader.GetForEntityAsync returns Task<IReadOnlyList<AuditHistoryEntryDTO>>; the MMCA.Common.Application.Auditing namespace no longer exists. No consumer references the old type. Replace the using. See UPGRADING.md.
Breaking (binary only): SetConcurrencyETag(object?) moves from EntityControllerBase<TEntity, TEntityDTO, TIdentifierType> to ApiControllerBase. Source compatible: every existing call compiles unchanged, so a recompile is the whole fix. See UPGRADING.md.
Breaking (MMCA.Common.Testing.Architecture): FormsConventionTestsBase.RequiredMarkers now expects the CreatePageBase member names (IsDirtyAccessor="() => IsDirty", @ref="Form", Result="SaveResult", Messages="Form?.Errors"; the _isDirty, _saveResult and _form markers are gone), and the new fact AdminCreateForms_InheritCreatePageBase requires every *Create.razor to derive from CreatePageBase. A consumer subclass is red until its create forms move to the base: rename _isDirty to IsDirty, _form to Form, _saveResult to SaveResult and _cts.LifetimeToken() to PageToken. Every create form moves in the same change as the upgrade: overriding RequiredMarkers does not satisfy AdminCreateForms_InheritCreatePageBase. See UPGRADING.md.
Breaking (MMCA.Common.Aspire.Hosting): WithE2eForceServer(bool unlessAlso), WithE2eRegistrationThrottleLift(bool alsoLiftWhen) and WithE2eGatewayRateLimitLift(bool alsoLiftWhen) lose their parameter; the helpers read E2E_FORCE_WASM themselves. Fix: delete the argument; replace an inline E2E_FORCE_WASM parse with ui.WithE2eForceWebAssembly(); make App.razor use E2eRenderMode.Resolve(Configuration). See UPGRADING.md.
Added
[PublicCachedReads(policyName)](MMCA.Common.API,MMCA.Common.API.Caching). A controller convention for an entity controller: the inherited base reads (bothGetAllAsyncoverloads,GetAllForLookupAsync,GetByIdAsync, inherited or overridden) get[AllowAnonymous]and[OutputCache(PolicyName = ...)]endpoint metadata, each only when the selector does not already carry one (an override's own[OutputCache]wins), and the by-id read gets the route nameByIdRouteName ?? "Get{EntityName}ById"unless an override declared one. The export, the writes and app-declared actions are untouched; nothing is added to the action filters. A controller not deriving fromEntityControllerBasethrows when the convention runs, and so does an overridden base read that declares its own[Authorize]or permission attribute without[AllowAnonymous]: the anonymous marker would otherwise replace that requirement silently, so a read that needs a gate belongs on a controller without the attribute. Authorization declared on the controller class (gated writes, public reads) is unaffected.[RefuseMachineClients](MMCA.Common.API,MMCA.Common.API.Authorization). An authorization filter for a controller or an action where a person takes part (casting a vote, saving a bookmark, checking in). A machine client holds what its backing role holds, so an endpoint that asks only for a signed-in caller is open to every machine client; this attribute answers a request whose token carriestoken_use=machinewith 403 (a problem response with the error codeMachineClients.EndpointRefused) and lets every other caller through. Declared on a controller it covers every action, the permission-gated ones included. It adds nothing to the API description, and a controller that does not carry it is unchanged.[EvictOutputCache(params tags)](MMCA.Common.API,MMCA.Common.API.Caching). A class- or method-level result filter: a write (not GET or HEAD) whose final status is 2xx evicts the tags before the response body, best effort throughTryEvictTagsAsyncunderCancellationToken.None, after[SupportsIfMatch]rewrote a conflict to 412 (which evicts nothing). Each tag is evicted once per request (EvictedTagsItemKey). NoIOutputCacheStoreregistered means nothing is evicted.- Conditional-write helpers on
ApiControllerBase(MMCA.Common.API).HandleConditionalCommandAsync(204) andHandleConditionalCommandWithResponseAsync(200 with the handler value and itsETag) read theIf-Matchtoken[SupportsIfMatch]decoded, build the command from it, dispatch it, await an optionalonSuccesscallback, and answerHandleFailureon failure. Without[SupportsIfMatch]they throwInvalidOperationExceptionand the handler never runs.SetConcurrencyETagnow resolves the token from the row's runtime type (cached per type), so any controller can use it. - Cursor paging on list endpoints (MMCA.Common.Application, MMCA.Common.API).
IEntityQueryService.GetPageByCursorAsync(KeysetPageRequest, specification, ct), a default interface member answeringCursorPagingNotSupportedErrorCode(500) for an implementation that predates it, overridden byEntityQueryService(sort column validated against the response contract and mapped throughDTOToEntityPropertyMap).[SupportsCursorPaging]on a controller opts in toGET {controller}/cursor(EntityControllerBase.GetCursorPageAsync); a controller without it has no such endpoint and an unchanged OpenAPI document. - Change feed with tombstones (MMCA.Common.Shared, MMCA.Common.Application, MMCA.Common.API).
EntityChange<TEntityDTO, TIdentifierType>(Id,IsDeleted,ChangedOn,Item);IEntityQueryService.GetChangesSinceAsync(since, cursor, pageSize, specification, ct), a default interface member answeringChangeFeedNotSupportedErrorCode(500), overridden byEntityQueryService: one keyset read over(LastModifiedOn, Id)ascending with the soft-delete filter lifted (the tenant filter stays),sinceinclusive and normalized to UTC, deleted rows as tombstones with no item.[SupportsChangeFeed]opts a controller in toGET {controller}/changes(EntityControllerBase.GetChangesAsync). - Bounded upload reads (MMCA.Common.API,
MMCA.Common.API.Uploads).IFormFile?.ReadBoundedAsync(maxBytes, invalidUpload, ct)returnsUploadedFileContent(bytes, declared file name and content type) or a failure carrying exactly the caller's error for a missing, empty, oversize or truncated file;IFormFile?.ReadCsvAsync(...)adds CSV parsing and returnsCsvUpload(the raw upload and theCsvTable), passing the reader'sCsv.MalformedandCsv.Emptyerrors unchanged. - Import report (MMCA.Common.Shared,
MMCA.Common.Shared.Imports).ImportReportDTO(DryRun,TotalRows,Added,Updated,Unchanged,Rejected,RowErrors, never null), for an application to derive its own counts from;ImportRowErrorDTOwithFromError(error, lineNumber, column);ImportConventions.DryRunFieldName(dryRun) and the documented dry-run convention. - Audit-history endpoint base (MMCA.Common.API,
MMCA.Common.API.Controllers.Administration).AuditHistoryControllerBase(reader, params auditedEntityTypes)servesGET {prefix}/{entity}/{key}?page=&pageSize=as a bare array ofAuditHistoryEntryDTO, newest first, withAuditHistory.UnknownEntity(404) andAuditHistory.KeyRequired(400) refusals; the class is gated on the newAdministrationPermissions.ViewAuditTrail(audit:view, MMCA.Common.Shared). A host mounts it with a one-line derived controller. - Audit-history panel and client (MMCA.Common.UI), the UI half of the endpoint above.
services.AddAuditHistoryUI()(once, afterAddUIShared;TryAddScoped, idempotent) registersIAuditHistoryUIService(MMCA.Common.UI.Services.Auditing), whoseGetPageAsync(routePrefix, entityAlias, entityKey, page, pageSize, ct)sends one bearer-authenticated, retried GET to{routePrefix}/{alias}/{key}?page=&pageSize=(alias and key escaped) and returns the bare array asAuditHistoryEntryDTOs, or a failedResultfor a non-2xx answer or a transport fault.AuditHistoryPanel(MMCA.Common.UI.Components.Auditing) renders the entries over aFetchPage(page, pageSize, ct)delegate as a table (when inDisplayTimeZone, default UTC, with the zone name; who throughUserLabel, or "System" for a null user; the change kind; the field throughPropertyLabel; before; after), fetches nothing during prerender, offers "Load more" while a page comes back full (PageSize, default 20), shows the shared empty and retry states, and cancels its fetch onReloadAsync()and on dispose. The panel is opt-in; nothing in the framework renders it. New shared resource keys underAuditHistory.*. - Offline write queue (
IOfflineWriteQueue,AddOfflineWriteQueue,AddOfflineWriteHandler,OfflineWriteNotifier) (MMCA.Common.UI,MMCA.Common.UI.Services.Offline). A durable queue of writes made offline, kept as one list in the device'sILocalCacheStore(IsAvailableis false on Blazor Server) and scoped to the signed-in user.EnqueueAsync(kind, payload, options)stamps an id, an idempotency key (supplied or a new GUID), the payload as web JSON, the user, the time and an optional row version (PendingWrite.IfMatchformats it as the weak ETag) and coalesce key, refusing withOfflineWrites.UnavailableorOfflineWrites.QueueFull;CancelPendingAsync(coalesceKey)drops writes that cancel each other ...
v1.288.0
Added
net11.0platform targets in MMCA.Common.UI.Maui. The package now carriesnet11.0-android,-ios,-maccatalystand-windows10.0.19041.0beside the fournet10.0ones, so a MAUI head on either .NET version finds its assets. Thenet11.0targets depend on MAUI 11.0.10, CommunityToolkit.Maui 17.0.0 and Xamarin.AndroidX.Biometric 1.1.0.33; thenet10.0targets are unchanged (MAUI 10.0.130, 16.0.0, 1.1.0.30). No source change and no API change. A head that moves tonet11.0-androidcompiles against Android API 37: aTargetPlatformVersionof 36.0 is rejected by the .NET 11 Android workload (NETSDK1140), so review the head's target API level when it moves.
v1.287.0
Security patch release for the 2026-11-10 Microsoft advisories, plus two opt-in or framework-conditional additions. No breaking change.
Security
- ASP.NET Core 10.0.15 (CVE-2026-114147, GHSA-wr8v-9x5q-ggcm, high, denial of service). On
net10.0the packages now depend on ASP.NET Core,Microsoft.Extensions.*and EF Core 10.0.15 (was 10.0.14);net11.0was already on the patched 11.0.0. Anet10.0host must also move its runtime image to 10.0.15: the advisory covers the shared runtime, not only the packages. - Aspire 13.6.5 (CVE-2026-120006, GHSA-f7vh-h5g3-7jrh, high, elevation of privilege in
Aspire.Hosting). Every Aspire pin moves from 13.6.4; a consumer AppHost should move its ownAspire.Hosting.*pins andAspire.AppHost.Sdkto match. Microsoft.AspNetCore.WebUtilities2.3.14 (CVE-2026-118750, GHSA-6527-9fp8-r4x6, moderate, denial of service). MMCA.Common.UI lifts it directly, becauseMicrosoft.AspNetCore.Mvc.Core2.3.14 still resolves the vulnerable 2.3.12.System.Formats.Cbor10.0.15 / 11.0.0 (CVE-2026-118747, GHSA-vmv3-wvjp-wqpj, moderate, denial of service in the CBOR reader). MMCA.Common.Passkeys lifts it directly off the 9.0.0 that Fido2 resolves.
Added
PushNotifications:CloseConnectionsOnTokenExpiry(MMCA.Common.Infrastructure, MMCA.Common.API; off by default). When on,MapNotificationHub()has the server close a hub connection when the access token it was authenticated with expires. The shipped client reconnects at once with a fresh token; a user who was locked, lost a role or signed out everywhere cannot obtain one and is refused, so an open connection no longer outlives a revoked session by more than one token lifetime. It needs neither negotiation nor a sticky ingress: each replica enforces it on the connections it holds.
Changed
- Zstandard response compression on .NET 11 (MMCA.Common.API).
AddCommonResponseCompression()registers ASP.NET Core 11'sZstandardCompressionProviderahead of Brotli and Gzip, so anet11.0host answers a client that acceptszstdwithContent-Encoding: zstd. Anet10.0host is unchanged (Brotli, then Gzip), and so is the middleware order. No public API change.
v1.286.0
Every package now ships for .NET 10 and .NET 11 from one source tree. No public API changes: the surface is identical on both target frameworks.
Added
net11.0assets in every package (all packages except MMCA.Common.UI.Maui, which stays on its fournet10.0platform targets for now). Each.nupkgcarrieslib/net10.0andlib/net11.0; a consumer gets the one matching its own target framework and changes nothing. The target frameworks are named once, inbuild/Frameworks.props, which also states the rules for version-specific code (#if NET11_0_OR_GREATERonly, with the existing code as the#elsebranch).
Changed
- Per-framework dependency versions. On
net11.0the packages depend on ASP.NET Core andMicrosoft.Extensions.*11.0.0 and onMicrosoft.OpenApi3.11.0; onnet10.0they stay on 10.0.14 and 2.13.0. Entity Framework Core stays on 10.0.14 for BOTH frameworks: the onlyNpgsql.EntityFrameworkCore.PostgreSQL11 build is a release candidate that pins EF Core to that exact candidate. EF Core 10 runs on the .NET 11 runtime. - Built with the .NET 11 SDK and C# 15.
global.jsonpins SDK 11.0.100 andLangVersionis15.0instead ofpreview. A consumer building MMCA.Common from source (local.props) needs the .NET 11 SDK; a consumer on packages does not. - A dropped browser connection no longer throws from three UI JavaScript call sites (MMCA.Common.UI): the file-download button,
DocumentLanguageand the list-page scroll tracking now treatJSDisconnectedExceptionas "nothing left to update" instead of letting it escape.
Notes for a .NET 11 host
- An ASP.NET Core 11 host emits OpenAPI 3.2 by default and orders tags alphabetically. Set
OpenApiOptions.OpenApiVersionin the host's ownAddOpenApi(...)call to keep a committed document stable across frameworks. - NuGet reports
System.Security.Cryptography.Pkcs9.0.18 (GHSA-846f-f764-76mx, GHSA-2fmv-rv2c-59x7) onnet11.0projects that reference the ASP.NET Core shared framework andMicrosoft.Data.SqlClient. It is a false positive: the shared framework supplies 11.0.0 and that is what loads.Directory.Build.propshere carries the twonet11.0-onlyNuGetAuditSuppressentries with the removal condition; a consumer host that sees the same two warnings can carry the same pair until a Pkcs 11.0.1 exists.
v1.285.0
Dependency release from the 2026-11-10 NuGet audit. No public API changes.
Changed
- MAUI 10.0.130 (MMCA.Common.UI.Maui).
Microsoft.Maui.ControlsandMicrosoft.AspNetCore.Components.WebView.Mauimove from 10.0.120; consumer heads pin the same version. - CommunityToolkit.Maui 16.0.0 (MMCA.Common.UI.Maui), from 15.0.1. The toolkit now throws
ArgumentNullExceptionat the call for a null argument it cannot handle, and on AndroidCommunityToolkit.Maui.Coreadds a launcher<queries>entry to the merged manifest. Heads keep callingUseMauiCommunityToolkit();MauiSpeechToTextServiceneeds no change. - Minor and patch updates: xunit.v3 and xunit.v3.extensibility.core 4.0.3 (Microsoft.Testing.Platform 2.5.2; a consumer pinning
Microsoft.Testing.Extensions.Retrymoves it to 2.5.2 with it), bunit 2.14.0 with AngleSharp 1.10.1, Polly and Polly.Core 8.9.1, AspNet.Security.OAuth.Apple and .GitHub 10.1.0, OpenTelemetry.Instrumentation.AspNetCore and .Http 1.20.2, Anthropic 13.7.0, OpenAI 2.15.0, Scalar.AspNetCore 2.19.0, Meziantou.Analyzer 3.0.322, and AWSSDK patch updates.
v1.284.0
A filter changed on a phone list now survives leaving the page and coming back.
Breaking: ListPageActions.ReloadActiveLayoutAsync(isMobile, mobileList, dataGrid) is removed (MMCA.Common.UI). A page derived from DataGridListPageBase<TDto> calls the new protected ReloadActiveLayoutAsync(mobileList) instead, which reloads GridRef on a desktop. See UPGRADING.md.
Added
DataGridListPageBase<TDto>.ReloadActiveLayoutAsync(MobileInfiniteScrollList<TDto>?)(MMCA.Common.UI). Reloads whichever layout is rendered. On a phone it callsSaveFilterState()and then resets the card list; otherwise it reloadsGridRef, whose fetch saves on its own.
Fixed
- Phone list filters are saved (MMCA.Common.UI). A
MobileInfiniteScrollListfetches through the page's own delegate, which saved nothing, so a search or filter changed on a phone was lost on leaving the page, and an older saved value came back on return (ADC Run 16, D6).UserAdminListsearch is saved the same way.
v1.283.0
Phone fixes from ADC manual test Run 16: photo uploads are sized on the device, the page bottom clears the Android navigation bar, a denied camera no longer opens a black scanner, and external sign-in logs how each attempt ended.
Breaking: IBarcodeScannerService gains EnsureCameraAccessAsync(CancellationToken) (MMCA.Common.UI), so a caller can explain a denied camera. A custom implementation adds the member; NullBarcodeScannerService returns false. MauiExternalAuthBroker takes an ILogger<MauiExternalAuthBroker> as a new last constructor argument (MMCA.Common.UI.Maui); DI supplies it. See UPGRADING.md.
Added
DataGridListPageBase<TDto>.SaveFilterState()(MMCA.Common.UI). Saves the page's current filters to the URL, in-memory state and sessionStorage, keeping pager and sort. For a page whoseMobileInfiniteScrollListfetches through its own delegate, which saves nothing.- External sign-in exit logging (MMCA.Common.UI.Maui).
MauiExternalAuthBrokerlogs one start line and the exit taken, with the elapsed time, at Information, so the in-app diagnostics log shows it. Lines carry fixed exit names and the server's error name only, never the callback's code or state or any exception text.
Fixed
- Photos are downscaled on the device (MMCA.Common.UI.Maui).
MauiMediaPickerServicefits take and choose results within 1024 px at JPEG quality 85, applies the rotation to the pixels and drops the metadata. The platform returns the original file when it cannot process one, so a caller with a size limit still checks it. - The page bottom clears the Android navigation bar (MMCA.Common.UI, UI.Maui).
MainPageBaseadds aMmcaNavigationBar/<css px>user agent token,theme-prepaint.jsexposes it as--mmca-navigation-bar-fallback, and the footer and drawer pad with the larger of that andenv(safe-area-inset-bottom), which reads 0 on a cold launch. - A denied camera no longer opens a black scanner (MMCA.Common.UI.Maui).
MauiBarcodeScannerServicerequests the permission before the scan page opens; a denial reads as no scan. - The diagnostics log toolbar wraps (MMCA.Common.UI) instead of overflowing its card on a phone.
- The language button has an accessible name on Android (MMCA.Common.UI). The Android WebView exposes no name for an
aria-labelon a button witharia-haspopup, which MudMenu always sets, soCultureSwitchernames its activator with visually hidden text.
v1.282.0
Reliability wave: the hub tells a subscriber when a connection has been restored, view-as works over gRPC, an open circuit no longer spends the outbox retry budget, retention deletes are batched, long-running scheduled jobs renew their lease, and the deleted-user marker outlives a raised access-token lifetime.
Breaking: DeleteUserHandlerBase<TUser, TCommand> takes ISoftDeletedUserMarkerWriter markerWriter in place of ICacheService cacheService (MMCA.Common.Application; ADR-047), so a host that raises the access-token lifetime cannot silently keep a 20-minute deleted-user marker. A subclass swaps the parameter on its own constructor and passes it to the base as the second argument; AddApplication registers the writer, so no DI change is needed. See UPGRADING.md.
Added
NotificationHubService.OnConnectionRestored(MMCA.Common.UI). Subscribes aFunc<Task>handler (dispose the returned subscription to unsubscribe) that runs once for each restored connection, after the rejoin of its held channels was attempted, on both reconnect paths (the automatic reconnect and the restart after a close), and even if a join failed, because a page should still refresh once. It does not run for the first successful start of a connection or once the service has been stopped or disposed. SignalR delivers nothing that was sent while the connection was down, so a subscriber re-reads what it shows live. Handlers are awaited one after another; one that throws is logged and affects neither the rejoin nor the other handlers.ViewAsReadOnlyEndpointAttribute(MMCA.Common.Shared, namespaceMMCA.Common.Shared.Auth; ADR-137). A host marks a read-only gRPC service or method with it, andViewAsGuardMiddlewarethen treats a POST routed to that endpoint as safe for the METHOD rule only (any other method is still refused):ViewAs:Enabledand the grant-liveness check still run, there is no exemption by content type or for gRPC in general, andViewAsCommandDecoratoris unchanged. Without the mark, a view-as read that fans out over gRPC is refused on the peer, because gRPC is always POST.McpToolPublicAttribute(MMCA.Common.Mcp) andMcpToolAccessDeclarationTestsBase(MMCA.Common.Testing.Architecture; ADR-138).[McpToolPublic]records that a tool is public on purpose, with no runtime effect. The test base fails when an[McpServerTool]method, or an override of one, carries anything other than exactly one of[McpToolPermission]or[McpToolPublic]of its own; the subclass suppliesTargetAssembliesand an optionalMinimumMcpToolsfloor. The rule is also exposed asArchitectureRules.McpToolsDeclareAccessandArchitectureRules.CountMcpTools.ISoftDeletedUserMarkerWriterandSoftDeletedUserMarkerWriter(namespaceMMCA.Common.Application.Auth.SoftDeletedUsers), registered scoped byAddApplicationand needingITokenServiceonly when resolved, plusSoftDeletedUserCache.MarkerDurationForandSoftDeletedUserCache.MarkDeletedForAsync(MMCA.Common.Application; ADR-047); see Fixed.
Changed
- Retention deletes run in batches of 1000 (MMCA.Common.Infrastructure). The outbox processed and dead-letter purges, the inbox purge, the internal-command processed and dead-letter purges and the refresh-session purge were single unbatched deletes; they now share the loop
AuditTrailCleanupJobused (ids read a page at a time, deleted by id). Cancellation is observed between batches, the total deleted is logged even when a later batch throws or the sweep is cancelled mid-batch, a soft-deletable entity is refused, and it runs on every relational engine the purges support. The refresh-session sweep orders by its key. No new table or column. - A scheduled job's lease is renewed while it runs (MMCA.Common.Infrastructure).
ScheduledJobRunnerextendsLockedUntilevery halfScheduler:LeaseSeconds, guarded by the claim token and on the cycle's own idle context, stopped before the completion stamp. A renewal matching no row means another replica owns the row: the run is cancelled, nothing is stamped, and one Warning names the job. A failed renewal is logged and retried sooner. A replica that dies stops renewing, so its claim is released when the lease expires, as before.
Fixed
- An open circuit no longer consumes the outbox retry budget (MMCA.Common.Infrastructure, Shared; ADR-087). A
BrokenCircuitExceptionwas charged to the row like a failed publish, so a long broker outage dead-lettered rows that never reached the broker. It no longer incrementsRetryCountand cannot dead-letter the row; the row is re-leased for aboutBrokerResilienceDefaults.BreakDurationplus jitter, capped at the lease, and keepsLastError, thebroker.circuit.open.countcounter and the once-per-batch log. Real publish failures and failed half-open probes keep counting. The breaker now also runs on the processor's injectedTimeProvider. - A scheduled job longer than its lease no longer runs twice (MMCA.Common.Infrastructure). On two or more replicas such a job ran concurrently, its completion stamp was dropped and the schedule never advanced; the renewal above closes it.
- The deleted-user marker outlives a raised access-token lifetime (MMCA.Common.Application, API; ADR-047).
SoftDeletedUserCache.MarkerDurationstays 20 minutes, the floor.DeleteUserHandlerBase(throughISoftDeletedUserMarkerWriter),SoftDeletedUserMiddlewareandMachineClientServicenow write max(20 minutes, access-token lifetime plus clock skew), so above 15 minutes a deleted user is no longer served on a host without the validator once the marker lapses. The middleware reads the host's bearer clock skew; the two Application-layer writers assume the 5-minute default.
v1.281.0
Generic OIDC sign-in, passkeys, machine clients, reloadable feature flags, a permission coverage gate, and background-job failures that keep their cause.
A new package, MMCA.Common.Passkeys, joins the framework (the only package that references Fido2NetLib and its native crypto dependency).
A host that turns on reloadable feature flags, passkeys or machine clients needs one migration per opt-in (a FeatureFlagOverrides table on each database that owns overrides, a PasskeyCredentials or MachineClients table on the Identity database); see UPGRADING.md. Nothing changes for a host that opts into none of them.
Added
-
Generic OIDC sign-in providers (MMCA.Common.API, Shared, UI; ADR-036).
AddExternalAuthProvidersalso registers one OpenID Connect scheme perOAuth:Oidc:<Name>section that sets aClientId(withAuthorityandClientSecretrequired, optionalScopes,DisplayName,DomainsandTrustEmail, default false): authorization code flow with PKCE into the existing external-login cookie, callback at/auth/callback/<Name>. A misconfigured section fails startup naming the key; the names google, github, apple, complete, exchange and discover are reserved, a name must match^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$, and a domain claimed by two providers fails startup. The provider key the app'sExternalLoginAsyncreceives is{tid}|{oid}when the ID token carries both (Entra ID), else{iss}|{sub}, never the email;email_verified=truereaches the app only when the provider setsTrustEmailAND the token asserts it, so an app that links by verified email never links on an unverified Entra address.OAuthControllerBasegainsGET auth/oauth/{provider}(the literal google, github and apple routes keep precedence; 404 for an unknown name) andGET auth/oauth/discover?email=(anonymous, on theauth-ippolicy; exact domain match;OidcDiscoveryResponseor 404). The Common.UI login shows one button per configured provider and, when any provider declaresDomains, a "Sign in with your organization" email-first step (English and Spanish); a WebAssembly client lists a provider when its client configuration carriesOAuth:Oidc:<Name>:Enabled. Entra ID tokens carryemailonly when that optional claim is configured on the app registration; without it the sign-in ends withmissing_claims(there is deliberately no fallback to the unverifiedpreferred_username). -
Passkeys (WebAuthn) (MMCA.Common.Passkeys, new; MMCA.Common.Shared, Domain, Application, Infrastructure, API, UI, UI.Web; ADR-139). A phishing-resistant sign-in and second factor for web heads.
services.AddPasskeys(configuration)from the new package (Passkeys:Enabled,Passkeys:RelyingPartyId,Passkeys:RelyingPartyName,Passkeys:Origins,Passkeys:DataSourceName, validated at startup) registers the Fido2NetLib-backedIPasskeyCeremony; no other framework package references Fido2, NSec or libsodium (EnforcePasskeysBoundaryat build time plusPasskeysIsolationTests).Passkeys:Enabledtrue without the call fails startup naming it.PasskeysControllerBase(abstract, underauth/passkeys):register/optionsandregisterfor a signed-in user whose interactive sign-in (auth_time) is under 10 minutes old;GET,PATCH {id}(rename) andDELETE {id}(the last passkey goes only while a password or an external login remains, under a per-account lock); anonymoussignin/optionsandsigninon theauth-ippolicy, with discoverable credentials. Challenges are single-use and live 5 minutes in the shared cache; a decreasing signature counter is refused (0 to 0 is allowed for synced passkeys). A passkey sign-in mints the normal token pair and refresh session throughAuthenticationServiceBase.SignInWithPasskeyAsync, with the newmfavaluepasskey.PasskeyCredentials(hard-deleted, so a removed key can never sign in) is mapped only where enabled. Common.UI: aPasskeysPanelcard for the profile page and a passkey button plus browser autofill on Login whenSignIn:OfferPasskeysis true; autofill asks for a challenge only on a browser that supports it, and MAUI heads hide passkeys. New consumer hooks onIAuthenticationService:SignInWithPasskeyAsyncandHasSignInMethodBesidesPasskeysAsync(both implemented byAuthenticationServiceBase), andAuthenticationServiceBase.HasExternalLoginAsync(protected virtual, default false: override it when users can sign in through an external provider). The anonymous passkey sign-in path joins the same-origin proxy's default token-issuing paths. -
Machine clients through OAuth client credentials (MMCA.Common.Shared, Domain, Application, Infrastructure, API, UI; ADR-140). Programs get a short-lived bearer token every service, and every permissioned MCP tool, already validates.
services.AddMachineClients(configuration)(MachineClients:Enabled,MachineClients:AccessTokenMinutesdefault 15 and at most 15 so a token never outlives the revocation marker,MachineClients:DataSourceName) with a consumer-suppliedIServiceAccountProvisioner(CreateAsync(name, role)returning the new user's id,FindAsync(userId)returning aServiceAccount(UserId, Name, Email, Role)); enabled without the provisioner, or without the call, fails startup naming it. Each client is backed one-to-one by its own service-account user, so audit stamps, ownership and the per-user rate-limit partition work unchanged;IAuthUser.IsServiceAccount(defaulted, false) andViewAsSubject.IsServiceAccount(init-only) let password login, sign-in links, external login, passkeys and view-as refuse such an account.MachineTokenControllerBase(route it atauth/token): anonymousPOST,grant_type=client_credentialswith HTTP Basic or form credentials, on theauth-ippolicy, answering RFC 6749 JSON orinvalid_client/unsupported_grant_type. The token'ssubis the service account, its permissions are the role's, and it carriesclient_idandtoken_use=machine, with no refresh token, session,auth_timeormfa. Secrets are 32 random bytes shown once, stored as SHA-256 and compared in constant time; rotate issues a secondary, promote retires the old primary, revoke writes the soft-deleted marker so every service rejects outstanding tokens. A role grantingusers:view-as,users:manage,roles:manageorclients:managecannot back a client, checked at create and at every mint.MachineClientsControllerBase(abstract, no route, the newAdministrationPermissions.ManageClients,clients:manage) lists, creates, rotates, promotes and revokes; a Common.UI admin page (no route) with English and Spanish resources.MachineClientsis mapped only where enabled (DesignTimeDbContextOptions.EnableMachineClients). -
auth_timeclaim (MMCA.Common.Shared, Application). Every interactive sign-in that opens a session (password, registration, sign-in link, external login, passkey) stampsAuthClaimTypes.AuthTime(Unix seconds); a refresh carries it unchanged and a view-as token has none. Passkey registration is its first reader, so a refreshed session cannot pass for a recent sign-in. -
Reloadable feature flags (MMCA.Common.Shared, Domain, Application, Infrastructure, API, UI; ADR-031).
AddReloadableFeatureFlags(configuration, params Assembly[] featureAssemblies), called afterAddAPI(), layers stored overrides (on, off, a sticky per-user percentage, or targeted users and groups) over theFeatureManagementconfiguration, so a kill switch flips without a new revision. Configuration stays the default; an override wins until cleared (a soft delete, so the audit fields record who cleared it). Overrides live in the owning service's database (FeatureFlags:DataSourceName, defaultDefault), are read as one snapshot throughICacheServiceunder a generation-suffixed key that every write invalidates (seconds across replicas with the two-level cache;FeatureFlags:OverrideCacheSeconds, default 30, bounds staleness otherwise), and only a flag declared with[FeatureFlag]in the given assemblies can be overridden (FeatureFlag.Undeclared, 404). When the snapshot cannot be loaded, every flag answers from configuration and a Warning is logged.FeatureFlagsControllerBase(abstract, no route,features:manage, the newAdministrationPermissions.ManageFeatures) servesGET(each declared flag with lifetime, owner, configured value, override and effective value),PUT {name}(aReasonis required) andDELETE {name}; theFeatureFlagAdminpage (no route: the app supplies it) shows one table across the flag sources given toservices.AddFeatureFlagAdministrationUI(params string[] sources), in English and Spanish. TheFeatureFlagOverridestable is mapped only whereAddReloadableFeatureFlagsis called; setDesignTimeDbContextOptions.EnableFeatureFlagOverrides = truein the owning service's migrations project to generate it. -
PermissionCoverageTestsBase(MMCA.Common.Testing.Architecture). A fitness test that fails when a controller, page, MCP tool or menu entry names a permission no role is granted, since nobody can ever pass such a gate. The subclass suppliesTargetAssemblies,DeclaredPermissions(passIPermissionCatalog.Permissionsof the registry the token issuer builds, which lists granted permissions only), optionalAdditionalReferencedPermissionsfor permissions named in code such asNavItem.RequiredPermission, and aMinimumNamedPermissionsnon-vacuity floor (default 1). It reads[HasPermission],[HasPagePermission],[McpToolPermission]and a plain[Authorize(Policy = "perm:...")]or"page-perm:...". Class-level attributes are read with inheritance, so an app controller derived from a framework base must grant the base's gate; method-level ones only where declared. The match is ordinal, as the permission registry's is. Only this direction is checked: a granted permission nothing names ...