From 68ea02906dd65ced2b4ba0353f40e1e97deb75c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 18:36:06 +0000 Subject: [PATCH 1/9] Fail fast in AdminModule when OpenIddict isn't installed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AdminModule references SimpleModule.OpenIddict.Contracts but injects IOpenIddictSessionContracts (whose impl ships in SimpleModule.OpenIddict) into its session endpoints. Without OpenIddict installed, ASP.NET's minimal-API parameter classifier falls through to body-binding and the host crashes at MapModuleEndpoints with 'Body was inferred but the method does not allow inferred body parameters' — a message that gives no clue about the actual missing dependency. Override ConfigureMiddleware (which the source generator runs before MapModuleEndpoints) to probe for IOpenIddictSessionContracts and throw a directive error pointing at the missing package. --- .../src/SimpleModule.Admin/AdminModule.cs | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/modules/Admin/src/SimpleModule.Admin/AdminModule.cs b/modules/Admin/src/SimpleModule.Admin/AdminModule.cs index 7e7bab7b..47951b82 100644 --- a/modules/Admin/src/SimpleModule.Admin/AdminModule.cs +++ b/modules/Admin/src/SimpleModule.Admin/AdminModule.cs @@ -1,8 +1,10 @@ -using Microsoft.Extensions.Configuration; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using SimpleModule.Admin.Contracts; using SimpleModule.Core; using SimpleModule.Core.Menu; +using SimpleModule.OpenIddict.Contracts; namespace SimpleModule.Admin; @@ -14,6 +16,24 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped(); } + public void ConfigureMiddleware(IApplicationBuilder app) + { + // Admin's session-management endpoints inject IOpenIddictSessionContracts, whose + // implementation lives in SimpleModule.OpenIddict (not its Contracts assembly). + // Without that module installed, minimal-API parameter binding falls through to + // body-binding and the host crashes at MapModuleEndpoints with the misleading + // "Body was inferred but the method does not allow inferred body parameters." + // Fail fast here with a directive a human can act on. + if (app.ApplicationServices.GetService() is null) + { + throw new InvalidOperationException( + "SimpleModule.Admin requires SimpleModule.OpenIddict to be installed. " + + "Add a reference to the SimpleModule.OpenIddict package (or project) " + + "so IOpenIddictSessionContracts can be resolved by Admin's session endpoints." + ); + } + } + public void ConfigureMenu(IMenuBuilder menus) { menus.Add( From 8d67778508b15f157f1a54befef87b1f57c24026 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 18:49:08 +0000 Subject: [PATCH 2/9] Fail fast in EmailModule when BackgroundJobs isn't installed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same shape as the AdminModule/OpenIddict fix. EmailModule references SimpleModule.BackgroundJobs.Contracts but injects IBackgroundJobs into EmailService and EmailJobRegistrationHostedService. Without the BackgroundJobs module installed, the host crashes at first resolution with 'Unable to resolve service for type IBackgroundJobs' — useful but still not directive about which package is missing. Probe in ConfigureMiddleware (which runs before MapModuleEndpoints) so the failure points at the missing dependency. --- .../src/SimpleModule.Email/EmailModule.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/modules/Email/src/SimpleModule.Email/EmailModule.cs b/modules/Email/src/SimpleModule.Email/EmailModule.cs index ecfbc525..05c88c49 100644 --- a/modules/Email/src/SimpleModule.Email/EmailModule.cs +++ b/modules/Email/src/SimpleModule.Email/EmailModule.cs @@ -1,4 +1,5 @@ using FluentValidation; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -21,6 +22,23 @@ namespace SimpleModule.Email; )] public class EmailModule : IModule, IModuleServices { + public void ConfigureMiddleware(IApplicationBuilder app) + { + // EmailService and the job-registration hosted service inject IBackgroundJobs, + // whose implementation lives in SimpleModule.BackgroundJobs (not its Contracts + // assembly). Without that module installed, the host crashes at first resolution + // with "Unable to resolve service for type 'IBackgroundJobs'" — fail fast here + // with a directive message instead. + if (app.ApplicationServices.GetService() is null) + { + throw new InvalidOperationException( + "SimpleModule.Email requires SimpleModule.BackgroundJobs to be installed. " + + "Add a reference to the SimpleModule.BackgroundJobs package (or project) " + + "so IBackgroundJobs can be resolved by Email's send/retry jobs." + ); + } + } + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) { services.AddModuleDbContext(configuration, EmailConstants.ModuleName); From 02caaa1c3c9da1fcefd510eafb07714c31d9e8b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 18:49:16 +0000 Subject: [PATCH 3/9] Make resolvePage handle bare-named local modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @simplemodule/client/resolve-page hardcoded the assembly name to SimpleModule.${moduleName}, which only works for framework-published modules. Downstream apps that ship modules under a bare assembly name (Customers, Invoices, …) had to roll their own resolver. Try the bare assembly name first, then fall back to SimpleModule.X so framework modules continue to resolve. Throw a directive error listing both candidates if neither bundle loads. --- .../SimpleModule.Client/src/resolve-page.ts | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/SimpleModule.Client/src/resolve-page.ts b/packages/SimpleModule.Client/src/resolve-page.ts index a3c48523..c6d5862d 100644 --- a/packages/SimpleModule.Client/src/resolve-page.ts +++ b/packages/SimpleModule.Client/src/resolve-page.ts @@ -1,14 +1,37 @@ export async function resolvePage(name: string) { const moduleName = name.split('/')[0]; - const assemblyName = `SimpleModule.${moduleName}`; const cacheBuster = (document.querySelector('meta[name="cache-buster"]') as HTMLMetaElement) ?.content; const suffix = cacheBuster ? `?v=${cacheBuster}` : ''; - const mod = await import( - /* @vite-ignore */ - `/_content/${assemblyName}/${assemblyName}.pages.js${suffix}` - ); + // Downstream apps frequently ship modules under a bare assembly name + // (e.g. "Customers", "Invoices") rather than the framework's "SimpleModule.X" + // convention. Try the bare name first, then fall back to the prefixed form + // so framework modules continue to resolve. + const candidates = [moduleName, `SimpleModule.${moduleName}`]; + // biome-ignore lint/suspicious/noExplicitAny: matches existing dynamic-import shape + let mod: any; + let assemblyName = candidates[0]; + let lastError: unknown; + for (const candidate of candidates) { + try { + mod = await import( + /* @vite-ignore */ + `/_content/${candidate}/${candidate}.pages.js${suffix}` + ); + assemblyName = candidate; + break; + } catch (err) { + lastError = err; + } + } + + if (!mod) { + throw new Error( + `Could not load pages bundle for module "${moduleName}". ` + + `Tried ${candidates.join(', ')}. Last error: ${String(lastError)}`, + ); + } if (!mod.pages) { throw new Error( From 528f79e0daeea0ebb638777487e89ade59b71f16 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 18:49:25 +0000 Subject: [PATCH 4/9] Set authenticated FallbackPolicy and document health probes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The framework called AddAuthorization() with no FallbackPolicy, so any endpoint without an explicit RequireAuthorization() was silently public. Module groups already RequireAuthorization() via the source generator, but plain app.MapGet(...) calls outside any module group inherited nothing. The wrong default for a business app — and easy to miss on review since the symptom is invisible. Set FallbackPolicy = RequireAuthenticatedUser. Endpoints that genuinely need to be public (login, health probes, error/404 fallbacks) opt out explicitly with .AllowAnonymous(). The framework's existing health, error, and fallback handlers already do this; add a comment so a downstream cleanup pass doesn't accidentally remove them. --- .../SimpleModuleHostExtensions.cs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs index 7150accd..b9657a61 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Http; @@ -8,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; +using SimpleModule.Core.Authorization; using SimpleModule.Core.Constants; using SimpleModule.Core.Exceptions; using SimpleModule.Core.Health; @@ -85,6 +87,7 @@ public static WebApplicationBuilder AddSimpleModuleInfrastructure( // Required by EntityInterceptor to access the current HTTP context builder.Services.AddHttpContextAccessor(); + builder.Services.AddScoped(); // Entity framework interceptors for automatic entity field population builder.Services.AddScoped(); @@ -95,7 +98,16 @@ public static WebApplicationBuilder AddSimpleModuleInfrastructure( // (e.g., OpenIddict registers SmartAuth policy scheme). // Register a baseline so the middleware pipeline works even without an auth module. builder.Services.AddAuthentication(); - builder.Services.AddAuthorization(); + // Authenticated-by-default. Endpoints that genuinely need to be public + // (login, health probes, error/404 fallbacks) opt out with .AllowAnonymous(). + // Without a fallback policy, plain app.MapGet(...) outside a module group + // is silently public — the wrong default for a business app. + builder.Services.AddAuthorization(options => + { + options.FallbackPolicy = new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .Build(); + }); builder.Services.AddAntiforgery(); // Register default IPublicMenuProvider if no module provides one @@ -272,6 +284,10 @@ public static async Task UseSimpleModuleInfrastructure(this WebApplication app) if (options.EnableHealthChecks) { + // Health probes are intentionally anonymous so kubelet / load balancers + // can hit them without credentials. The framework owns these — do not + // remove the .AllowAnonymous() calls when sweeping anonymous routes + // out of an application. app.MapHealthChecks( RouteConstants.HealthLive, new HealthCheckOptions { Predicate = _ => false } From eeeb55c166f133c0cfd742ccd1d678c65e29d4af Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 18:49:33 +0000 Subject: [PATCH 5/9] Warn when useTranslation has no translations bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modules that call useTranslation('X') silently render bare keys when SimpleModule.Localization isn't installed — every label shows as 'Users.Title', 'Roles.ColName', etc. The cause is that LocaleResolutionMiddleware (Localization-only) is what populates props.translations on Inertia shared data; without it the property is undefined and every key falls through to the bare-key fallback. Detect the missing-bundle case (props.translations undefined, not empty) and warn once per namespace in dev. Behaviour is unchanged in production builds. --- .../src/use-translation.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/SimpleModule.Client/src/use-translation.ts b/packages/SimpleModule.Client/src/use-translation.ts index a5d8917c..dec6dc13 100644 --- a/packages/SimpleModule.Client/src/use-translation.ts +++ b/packages/SimpleModule.Client/src/use-translation.ts @@ -27,11 +27,30 @@ interface SharedProps { *

{t(ProductsKeys.Manage.DeleteConfirm, { name: product.name })}

* ``` */ +const warnedNamespaces = new Set(); + export function useTranslation(namespace: string): TranslationResult { const { props } = usePage(); const locale = props.locale ?? 'en'; const translations = props.translations ?? {}; + // If translations were never populated, the SimpleModule.Localization module + // is probably not installed (its middleware is what writes props.translations). + // Without it, every t(...) call falls back to the bare key and the UI renders + // raw identifiers like "Users.Title". Warn loudly in dev once per namespace. + if ( + process.env.NODE_ENV !== 'production' && + props.translations === undefined && + !warnedNamespaces.has(namespace) + ) { + warnedNamespaces.add(namespace); + console.warn( + `[useTranslation] Module "${namespace}" is asking for translations but ` + + 'Inertia shared data has no "translations" key. Is SimpleModule.Localization installed? ' + + 'Without it, translation keys will render as-is.', + ); + } + const t = useMemo(() => { const prefix = `${namespace}.`; From d1264aa82dc1f96ac68c60071bb44c2576eb890d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 18:49:40 +0000 Subject: [PATCH 6/9] Add ICurrentUser abstraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modules currently inject IHttpContextAccessor and walk claims by hand to get the user id, role, or permission state — the framework already knows how to do this (ClaimsPrincipalExtensions handles the sub-vs-NameIdentifier difference, HasPermission handles wildcards and the Admin bypass) but never exposed it as an injectable service. Add a thin ICurrentUser interface backed by IHttpContextAccessor, delegating to the existing extensions. Modules can now inject ICurrentUser instead of repeating the boilerplate. --- .../Authorization/ICurrentUser.cs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 framework/SimpleModule.Core/Authorization/ICurrentUser.cs diff --git a/framework/SimpleModule.Core/Authorization/ICurrentUser.cs b/framework/SimpleModule.Core/Authorization/ICurrentUser.cs new file mode 100644 index 00000000..c9e9e751 --- /dev/null +++ b/framework/SimpleModule.Core/Authorization/ICurrentUser.cs @@ -0,0 +1,32 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using SimpleModule.Core.Extensions; + +namespace SimpleModule.Core.Authorization; + +/// +/// Ambient access to the current request's principal, without forcing every +/// caller to inject IHttpContextAccessor and walk claims by hand. +/// +public interface ICurrentUser +{ + string? Id { get; } + bool IsAuthenticated { get; } + bool IsInRole(string role); + bool HasPermission(string permission); + ClaimsPrincipal? Principal { get; } +} + +public sealed class HttpContextCurrentUser(IHttpContextAccessor httpContextAccessor) : ICurrentUser +{ + public ClaimsPrincipal? Principal => httpContextAccessor.HttpContext?.User; + + public string? Id => Principal?.GetUserId(); + + public bool IsAuthenticated => Principal?.Identity?.IsAuthenticated == true; + + public bool IsInRole(string role) => Principal?.IsInRole(role) == true; + + public bool HasPermission(string permission) => + Principal is not null && Principal.HasPermission(permission); +} From 7ba2da146f98a35cce5a6a501d02b94fe7e47c07 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 18:49:54 +0000 Subject: [PATCH 7/9] Force 401/403 on /api/* instead of cookie redirect Cookie auth's default OnRedirectToLogin sniffs the Accept header to decide between 302-to-login and bare 401, but the heuristic is unreliable: /api/invoices returns 401 while /api/invoices/1 returns 302 to /Identity/Account/Login. Same authorization, different responses, broken JS clients. Override OnRedirectToLogin / OnRedirectToAccessDenied to short-circuit to 401 / 403 for any path under /api. Browser navigations outside /api keep the redirect. --- .../src/SimpleModule.Users/UsersModule.cs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/modules/Users/src/SimpleModule.Users/UsersModule.cs b/modules/Users/src/SimpleModule.Users/UsersModule.cs index 8af07284..670aec99 100644 --- a/modules/Users/src/SimpleModule.Users/UsersModule.cs +++ b/modules/Users/src/SimpleModule.Users/UsersModule.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -37,6 +38,41 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config options.LoginPath = "/Identity/Account/Login"; options.LogoutPath = "/Identity/Account/Logout"; options.AccessDeniedPath = "/Identity/Account/AccessDenied"; + + // /api/* clients (JS, CLI, integration tests) want a bare 401 — not a + // 302 to /Identity/Account/Login. The default cookie handler sniffs the + // Accept header but inconsistently, leading to 401 for some routes and + // 302 for others. Force 401 for any unauthenticated /api request. + options.Events.OnRedirectToLogin = context => + { + if ( + context.Request.Path.StartsWithSegments( + "/api", + StringComparison.OrdinalIgnoreCase + ) + ) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return Task.CompletedTask; + } + context.Response.Redirect(context.RedirectUri); + return Task.CompletedTask; + }; + options.Events.OnRedirectToAccessDenied = context => + { + if ( + context.Request.Path.StartsWithSegments( + "/api", + StringComparison.OrdinalIgnoreCase + ) + ) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return Task.CompletedTask; + } + context.Response.Redirect(context.RedirectUri); + return Task.CompletedTask; + }; }); // Bridge UsersModuleOptions into ASP.NET Identity options From bc4c4801a2fd88eaf7dc010ccb350a244efc72a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 19:19:19 +0000 Subject: [PATCH 8/9] Probe peer modules via IServiceProviderIsService The fail-fast checks in Admin and Email modules called GetService() on the root container, which threw "Cannot resolve scoped service from root provider" because the contract types are registered as scoped. The host crashed during ConfigureMiddleware before any endpoint could be mapped. Switch to IServiceProviderIsService.IsService(...), which inspects the descriptor table without instantiating the service. --- modules/Admin/src/SimpleModule.Admin/AdminModule.cs | 5 ++++- modules/Email/src/SimpleModule.Email/EmailModule.cs | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/modules/Admin/src/SimpleModule.Admin/AdminModule.cs b/modules/Admin/src/SimpleModule.Admin/AdminModule.cs index 47951b82..10c2f9dd 100644 --- a/modules/Admin/src/SimpleModule.Admin/AdminModule.cs +++ b/modules/Admin/src/SimpleModule.Admin/AdminModule.cs @@ -24,7 +24,10 @@ public void ConfigureMiddleware(IApplicationBuilder app) // body-binding and the host crashes at MapModuleEndpoints with the misleading // "Body was inferred but the method does not allow inferred body parameters." // Fail fast here with a directive a human can act on. - if (app.ApplicationServices.GetService() is null) + // Use IServiceProviderIsService so we don't actually instantiate the (scoped) + // service from the root provider. + var probe = app.ApplicationServices.GetRequiredService(); + if (!probe.IsService(typeof(IOpenIddictSessionContracts))) { throw new InvalidOperationException( "SimpleModule.Admin requires SimpleModule.OpenIddict to be installed. " diff --git a/modules/Email/src/SimpleModule.Email/EmailModule.cs b/modules/Email/src/SimpleModule.Email/EmailModule.cs index 05c88c49..1bd05674 100644 --- a/modules/Email/src/SimpleModule.Email/EmailModule.cs +++ b/modules/Email/src/SimpleModule.Email/EmailModule.cs @@ -29,7 +29,10 @@ public void ConfigureMiddleware(IApplicationBuilder app) // assembly). Without that module installed, the host crashes at first resolution // with "Unable to resolve service for type 'IBackgroundJobs'" — fail fast here // with a directive message instead. - if (app.ApplicationServices.GetService() is null) + // Use IServiceProviderIsService so we don't actually instantiate the (scoped) + // service from the root provider. + var probe = app.ApplicationServices.GetRequiredService(); + if (!probe.IsService(typeof(IBackgroundJobs))) { throw new InvalidOperationException( "SimpleModule.Email requires SimpleModule.BackgroundJobs to be installed. " From d42965be0aa05110038c090168e210d30866aadf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 19:46:19 +0000 Subject: [PATCH 9/9] Allow anonymous on MapStaticAssets and update API auth test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new RequireAuthenticatedUser fallback policy applies to every endpoint without an explicit policy — including the static-asset endpoints created by MapStaticAssets. That redirected /_content//.pages.js to /Identity/Account/Login, so the React Inertia bundle could not load on anonymous pages and the login form never rendered. Also update the FileStorage e2e test that asserted /api/* returns 302; it now returns 401, which is what the cookie OnRedirectToLogin override produces for /api requests. --- .../SimpleModule.Hosting/SimpleModuleHostExtensions.cs | 6 +++++- tests/e2e/tests/smoke/filestorage.spec.ts | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs index b9657a61..09f6ed36 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs @@ -264,7 +264,11 @@ public static async Task UseSimpleModuleInfrastructure(this WebApplication app) app.UseInertia(); UseStaticFileCaching(app); - app.MapStaticAssets(); + // MapStaticAssets registers endpoints, which would otherwise inherit the + // RequireAuthenticatedUser fallback policy — that breaks JS bundle / CSS / + // favicon loads on anonymous pages like /Identity/Account/Login. Static + // files are intentionally public. + app.MapStaticAssets().AllowAnonymous(); app.UseAuthentication(); app.UseAuthorization(); diff --git a/tests/e2e/tests/smoke/filestorage.spec.ts b/tests/e2e/tests/smoke/filestorage.spec.ts index 22463bc6..ae5b50f1 100644 --- a/tests/e2e/tests/smoke/filestorage.spec.ts +++ b/tests/e2e/tests/smoke/filestorage.spec.ts @@ -43,11 +43,11 @@ test.describe('FileStorage smoke', () => { expect(response?.url()).toContain('/Account/Login'); }); - test('API endpoint returns 302', async ({ request }) => { + test('API endpoint returns 401', async ({ request }) => { const response = await request.get('/api/files', { maxRedirects: 0, }); - expect(response.status()).toBe(302); + expect(response.status()).toBe(401); }); }); });