using System.Reflection; using System.Text; using System.Text.Json; using System.Threading.RateLimiting; using EntitlementBLL.Auth; using EntitlementBLL.Common; using EntitlementBLL.Handlers; using EntitlementBLL.Options; using EntitlementSL.Common; using FastEndpoints; using FastEndpoints.Swagger; using GB5Shared.Auth.Jwt; using GB5Shared.Authorization; using GB5Shared.Connection; using GB5Shared.GB5CommonFunction; using GB5Shared.DTO.Framework.Enum; using GB5Shared.DTO.Framework.ResponseStandard; using GB5Shared.EventLogPublish; using GB5Shared.Vault; using GB5Shared.Deployment; using GB5Shared.Telemetry; using GB5Shared.DateConverter; using static GB5Shared.DateConverter.GB5JsonOptions; using GB5Shared.DTO.Framework.CommonConfig; using GB5Shared.GenerateAutoNumber; using GB5Shared.QueryExecutor; using GB5Shared.Validation; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.ResponseCompression; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.Caching.Hybrid; using Microsoft.IdentityModel.Tokens; using OpenTelemetry; using Quartz; Console.OutputEncoding = Encoding.UTF8; var builder = WebApplication.CreateBuilder(args); // ── Port from config — change "AppPort" in appsettings.json to use any port ── var appPort = builder.Configuration.GetValue("AppPort"); builder.WebHost.UseUrls($"http://0.0.0.0:{appPort}"); builder.Services.AddDaprClient(); builder.Services.AddHttpClient(); builder.Services.AddControllers() .AddDapr() .AddJsonOptions(options => { options.JsonSerializerOptions.PropertyNamingPolicy = null; options.JsonSerializerOptions.DictionaryKeyPolicy = null; options.JsonSerializerOptions.AddGB5Converters(); }); builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.PropertyNamingPolicy = null; options.SerializerOptions.DictionaryKeyPolicy = null; options.SerializerOptions.AddGB5Converters(); }); #pragma warning disable EXTEXP0018 builder.Services.AddHybridCache(options => { options.DefaultEntryOptions = new HybridCacheEntryOptions(); options.DisableCompression = false; }); #pragma warning restore EXTEXP0018 builder.Services.AddMemoryCache(); builder.Services.AddDistributedMemoryCache(); builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); // GOODBOOKS_ADMIN-gated endpoints authorize via [MenuRights] (Login-header + MROLEVSMENU, // same convention as every other GB5 module), not a JWT role — see Thread 0 §20. This only // registers IMenuRightsDAL/IMenuRightsBLL, both consuming the IQueryExecutor already above. builder.Services.AddMenuRightsAuthorization(); // Entitlement-specific configuration — EntitlementDbOptions (shared platform DB routing, // see that class's doc comment) and EcdsaKeyOptions (bundle signing key, dev placeholder here). builder.Services.Configure(builder.Configuration.GetSection(EntitlementDbOptions.SectionName)); builder.Services.Configure(builder.Configuration.GetSection(EcdsaKeyOptions.SectionName)); builder.Services.AddScoped(); // ClientOnboardingOrchestratorBLL (Thread 6/§31 Phase 3) — chains identity creation, // SqlWorkbench's ChangeRequest-governed DB provisioning, and (optionally) a reference-data // sync trigger. DefaultDbServerId/DbModelId/UpgradePackageId are resolved (appsettings.json, // tracker §31.11) against the real SW.MSWDBSERVER/MSWDBMODEL/MSWUPGRADEPACKAGE rows verified // live on 217.216.78.142/Gb5system in §31.7-§31.10 — re-verify before pointing at a different // target environment. ClientProvisioningOptions.DefaultAdminRoleCode is resolved too (§31.12, // "CLIENTADMIN" — a new dedicated MROLE row, resolved by RoleCode lookup at runtime, not a // hardcoded numeric ID). builder.Services.Configure(builder.Configuration.GetSection(ClientProvisioningOptions.SectionName)); builder.Services.Configure( builder.Configuration.GetSection(EntitlementBLL.Options.OnboardingOptions.SectionName)); // Named HttpClients for the two downstream modules the orchestrator calls over HTTP (no // project reference to SwDAL/SwBLL/FrameworkBLL exists or should exist — see // EntitlementBLL.Onboarding.ISqlWorkbenchClient's own doc comment). Base URLs point at // whichever real host serves those modules in the target environment; both are unset by // default (StartOnboardingAsync/ResumeAfterProvisioningExecutedAsync fail loudly, not // silently, if a call is attempted with no base address configured). builder.Services.AddHttpClient("SqlWorkbench", c => { var baseUrl = builder.Configuration["Integration:SqlWorkbenchBaseUrl"]; if (!string.IsNullOrWhiteSpace(baseUrl)) c.BaseAddress = new Uri(baseUrl); }); builder.Services.AddHttpClient("FrameworkDataSync", c => { var baseUrl = builder.Configuration["Integration:FrameworkDataSyncBaseUrl"]; if (!string.IsNullOrWhiteSpace(baseUrl)) c.BaseAddress = new Uri(baseUrl); }); // Entitlement↔Payment loose coupling (Docs/Platform-Architecture-Integration-Reference.md // §9.1) — same "unset by default, fail loudly not silently" convention as the two clients // above; no project reference to PAYDAL/PAYBLL exists or should exist (EntitlementBLL.Payment // .IPaySubscriptionClient's own doc comment). builder.Services.AddHttpClient("Pay", c => { var baseUrl = builder.Configuration["Integration:PayModuleBaseUrl"]; if (!string.IsNullOrWhiteSpace(baseUrl)) c.BaseAddress = new Uri(baseUrl); }); // Client-facing auth config (gb-ent-client Surface B) — was never bound here despite // ClientAuthBLL/ClientJwtService/etc. depending on it since the client-auth build; without this, // every client-auth endpoint (Login/RefreshToken/ChangePassword/ForgotPassword/...) would fail to // resolve at DI time, not just at runtime. builder.Services.Configure(builder.Configuration.GetSection(ClientJwtOptions.SectionName)); builder.Services.Configure(builder.Configuration.GetSection(ClientAccountLockoutOptions.SectionName)); builder.Services.Configure(builder.Configuration.GetSection(ClientPasswordResetOptions.SectionName)); builder.Services.AddScoped(); // Vault + shared JWT issuer — consolidated onto GB5Shared.Vault/GB5Shared.Auth.Jwt instead of a // module-local IVaultClient registration. AddGB5Vault registers IVaultClient/IVaultService (with // real caching/retry, unlike the old hand-rolled block this replaces); AddGB5JwtIssuer registers // the shared HMAC-SHA256 issuance mechanics ClientJwtService now delegates to (previously // duplicated byte-for-byte in DXPBLL.Auth.DXPJwtService). IClientSecretResolver is gone entirely — // it only ever served this one purpose, unlike DXP's DXPSecretResolver which also handles KYC // encryption and had to stay. builder.Services.AddGB5Vault(builder.Configuration); builder.Services.AddGB5JwtIssuer(); // Deployment tier (Singleton — GB5:Environment=Dev|QC|Live) — Entitlement's DB connection // name (EntitlementDb) resolves through IGB5Environment.Resolve() so Dev/QC/Live each hit a // separate physical database (see EntitlementLoginFactory). builder.Services.AddGB5Environment(builder.Configuration); // ClientJwtBearer — named, non-default JWT bearer scheme (Entitlement client sessions are // separate from GOODBOOKS_ADMIN's own internal auth model, so this must not become the default // scheme). RoleClaimType is mapped to ClientJwtClaimTypes.Role so FastEndpoints' Roles() actually // matches the "role" claim ClientJwtService issues (CLIENT_ADMIN/CLIENT_USER), not the .NET // default ClaimTypes.Role. Issuer/audience are plain config reads — no Vault involved. var clientJwtIssuer = builder.Configuration["Entitlement:ClientJwt:Issuer"]!; var clientJwtAudience = builder.Configuration["Entitlement:ClientJwt:Audience"]!; // PartnerM2MJwtBearer — second named scheme validating tokens issued by Partner's new // GetPartnerToken endpoint (a partner backend exchanges its TPARTNERAPIKEY secret there once for // a short-lived token, then calls PartnerM2MJwtBearer-protected Entitlement endpoints with it // instead of an unauthenticated PartnerProductId query parameter). Shares the signing key with // Partner's issuer at the same Vault path — a deliberate, documented exception to "each module // signs only its own tokens" (see PartnerM2MJwtOptions' doc comment), not a mistake. var partnerM2MIssuer = builder.Configuration["Partner:M2MJwt:Issuer"]!; var partnerM2MAudience = builder.Configuration["Partner:M2MJwt:Audience"]!; // Both schemes' signing keys are resolved lazily, on first token validation, via the shared // IJwtSigningKeyResolver (backed by IVaultService's own 5-minute cache) — NOT a synchronous Vault // fetch at startup. This used to be a justified exception ("runs before the host starts serving // requests"), but that justification stops holding once this same Register()-equivalent code path // runs inside a shared host composing multiple modules (see EntitlementModule.cs / PlatformHost) — // a blocked/unreachable Vault would then take down every other module sharing that process, not // just Entitlement. IssuerSigningKeyResolver's delegate signature is inherently synchronous (no // async extensibility point exists here), so the sync-over-async bridge below is a narrow, // unavoidable exception — but now confined to a single request thread on a cache miss, never the // host's startup path. builder.Services.AddAuthentication() .AddJwtBearer("ClientJwtBearer", _ => { }) .AddJwtBearer("PartnerM2MJwtBearer", _ => { }); builder.Services.AddOptions("ClientJwtBearer") .Configure((options, keyResolver) => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = clientJwtIssuer, ValidateAudience = true, ValidAudience = clientJwtAudience, ValidateLifetime = true, ValidateIssuerSigningKey = true, IssuerSigningKeyResolver = (token, securityToken, kid, validationParameters) => new[] { new SymmetricSecurityKey(Encoding.UTF8.GetBytes( keyResolver.GetSigningKeyAsync("entitlement/jwt-signing-key", CancellationToken.None) .GetAwaiter().GetResult())) }, ClockSkew = TimeSpan.FromSeconds(30), RoleClaimType = ClientJwtClaimTypes.Role }; }); builder.Services.AddOptions("PartnerM2MJwtBearer") .Configure((options, keyResolver) => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = partnerM2MIssuer, ValidateAudience = true, ValidAudience = partnerM2MAudience, ValidateLifetime = true, ValidateIssuerSigningKey = true, IssuerSigningKeyResolver = (token, securityToken, kid, validationParameters) => new[] { new SymmetricSecurityKey(Encoding.UTF8.GetBytes( keyResolver.GetSigningKeyAsync("partner/m2m-jwt-signing-key", CancellationToken.None) .GetAwaiter().GetResult())) }, ClockSkew = TimeSpan.FromSeconds(30) }; }); builder.Services.AddAuthorization(); builder.Services.Configure(options => { options.AllowSynchronousIO = true; }); builder.Services.AddResponseCompression(o => { o.EnableForHttps = true; o.Providers.Add(); }); builder.Services.AddGB5Telemetry(builder.Configuration, "GB5-Entitlement"); builder.Services.Configure(builder.Configuration.GetSection("Gb5SystemDTO")); // Forwarded-headers trust — without this, app.UseForwardedHeaders() below runs with the framework // default (ForwardedHeaders.None) and is a no-op: RemoteIpAddress/Scheme are never rewritten from // X-Forwarded-For/X-Forwarded-Proto, so every request behind a reverse proxy looks like it comes // from the proxy's own IP — collapsing the per-IP rate limiter below into one shared bucket for // every real caller. Mirrors GB5Framework/FrameworkSL/Program.cs's identical block, the one // correct template for this in the whole repo. builder.Services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; options.KnownNetworks.Clear(); options.KnownProxies.Clear(); }); // Per-IP rate limiting for Login/RefreshToken/ForgotPassword/ResetPassword/CreateClientAdmin — // these endpoints reference ClientAuthRateLimitPolicies via RequireRateLimiting(...), but until // now AddRateLimiter/UseRateLimiter were never actually registered, so those calls were inert // metadata: zero request throttling existed on any client-auth endpoint. Distinct from // GB5Shared.RateLimit's per-tenant limiter (partitioned by ClientId from the Login header) — these // endpoints are pre-authentication, so the caller's IP is the only durable partition key available. var clientAuthRateLimitOptions = new ClientAuthRateLimitOptions(); builder.Configuration.GetSection(ClientAuthRateLimitOptions.SectionName).Bind(clientAuthRateLimitOptions); builder.Services.AddRateLimiter(opts => { opts.RejectionStatusCode = 429; opts.OnRejected = async (ctx, ct) => { ctx.HttpContext.Response.ContentType = "application/json"; var body = new ResponseStandardDTO { Status = FrameworkEnumDTO.ResponseStatus.Failed, ErrorBody = "Rate limit exceeded. Please retry after a moment.", ErrorInnerException = string.Empty }; await ctx.HttpContext.Response.WriteAsync(JsonSerializer.Serialize(body), ct); }; opts.AddPolicy(ClientAuthRateLimitPolicies.LoginAndRefresh, httpContext => RateLimitPartition.GetSlidingWindowLimiter( ClientIpPartitionKey(httpContext), _ => new SlidingWindowRateLimiterOptions { PermitLimit = clientAuthRateLimitOptions.LoginRefreshPermitLimit, Window = TimeSpan.FromSeconds(clientAuthRateLimitOptions.LoginRefreshWindowSeconds), SegmentsPerWindow = 4, QueueLimit = 0, QueueProcessingOrder = QueueProcessingOrder.OldestFirst, AutoReplenishment = true })); opts.AddPolicy(ClientAuthRateLimitPolicies.CreateClientAdmin, httpContext => RateLimitPartition.GetSlidingWindowLimiter( ClientIpPartitionKey(httpContext), _ => new SlidingWindowRateLimiterOptions { PermitLimit = clientAuthRateLimitOptions.CreateClientAdminPermitLimit, Window = TimeSpan.FromSeconds(clientAuthRateLimitOptions.CreateClientAdminWindowSeconds), SegmentsPerWindow = 4, QueueLimit = 0, QueueProcessingOrder = QueueProcessingOrder.OldestFirst, AutoReplenishment = true })); opts.AddPolicy(ClientAuthRateLimitPolicies.SelfProvisionTrial, httpContext => RateLimitPartition.GetSlidingWindowLimiter( ClientIpPartitionKey(httpContext), _ => new SlidingWindowRateLimiterOptions { PermitLimit = clientAuthRateLimitOptions.SelfProvisionTrialPermitLimit, Window = TimeSpan.FromSeconds(clientAuthRateLimitOptions.SelfProvisionTrialWindowSeconds), SegmentsPerWindow = 4, QueueLimit = 0, QueueProcessingOrder = QueueProcessingOrder.OldestFirst, AutoReplenishment = true })); }); // Local — IP is the only durable partition key available pre-authentication. Depends on // ForwardedHeadersOptions above actually being configured to see the real client IP behind a proxy. static string ClientIpPartitionKey(HttpContext httpContext) => httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; // Assembly scan — auto-registers all BLL and DAL implementations (Feature/Plan/Subscription/ // Entitlement/License/Bundle/FeatureFlag/BetaClient/Audit/Provisioning services + DAL classes). var entitlementBllAssembly = Assembly.Load("EntitlementBLL"); var entitlementDalAssembly = Assembly.Load("EntitlementDAL"); // GB5Shared — needed for the forgot/reset-password flow's IDirectActionTokenService/ // IDirectActionTokenDAL/IEventActionRunDAL/IActionOutboxDAL (ClientAuthBLL.ForgotPasswordAsync/ // ResetPasswordAsync). These used to live in FrameworkBLL/FrameworkDAL and required blanket-scanning // both assemblies to pick them up — which also swept in FrameworkBLL's platform-wide background jobs // (OrphanDraftCleanupJob, WorkflowAutoApproveService, SchedulerBackgroundService, GopWorkerService, // etc.) via AsImplementedInterfaces(), and the host started them automatically, polling OTHER // tenants' databases — completely unrelated to Entitlement. Now that these 4 types live in // GB5Shared, no Framework scan is needed at all — just register them directly. builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); // EXCLUDE Exception — found via a real dotnet-run DI validation pass against PlatformHost // (EntitlementModule.cs, same scan): System.Exception implements ISerializable, so // AsImplementedInterfaces() was registering EntitlementBLL.Exceptions.* as ISerializable services, // then failing to construct them (their constructors take a string message, not DI-resolvable). builder.Services.Scan(scan => scan .FromAssemblies(entitlementBllAssembly, entitlementDalAssembly) .AddClasses(c => c.Where(t => !t.IsAbstract && !t.IsInterface && !typeof(Microsoft.Extensions.Hosting.IHostedService).IsAssignableFrom(t) && !typeof(Exception).IsAssignableFrom(t))) .AsImplementedInterfaces() .WithScopedLifetime()); // Quartz.NET — in-memory store (Phase 1; no clustered/persistent store yet, matching the // module's standalone-runnable bar). 5 jobs per the plan's background-job list. builder.Services.AddQuartz(q => { q.UseSimpleTypeLoader(); q.UseInMemoryStore(); // FeatureFlagSchedulerJob was here — cut over to Track A (tracker §45), see the comment // further down in this same block for KillSwitchMonitorJob/PayOrderReconciliationJob. var bundleRefreshKey = new JobKey("EntitlementBundleRefreshJob"); q.AddJob(opts => opts.WithIdentity(bundleRefreshKey)); q.AddTrigger(opts => opts.ForJob(bundleRefreshKey) .WithIdentity("EntitlementBundleRefreshJob-trigger") .WithCronSchedule("0 0 2 * * ?")); // daily 02:00 var licenseExpiryKey = new JobKey("LicenseExpiryWarningJob"); q.AddJob(opts => opts.WithIdentity(licenseExpiryKey)); q.AddTrigger(opts => opts.ForJob(licenseExpiryKey) .WithIdentity("LicenseExpiryWarningJob-trigger") .WithCronSchedule("0 0 8 * * ?")); // daily 08:00 var betaExpiryKey = new JobKey("BetaClientExpiryJob"); q.AddJob(opts => opts.WithIdentity(betaExpiryKey)); q.AddTrigger(opts => opts.ForJob(betaExpiryKey) .WithIdentity("BetaClientExpiryJob-trigger") .WithCronSchedule("0 0 3 * * ?")); // daily 03:00 // KillSwitchMonitorJob and PayOrderReconciliationJob were here — cut over to Track A // (GB5Framework's real MJOBDEFINE/TSCHEDULER scheduler, tracker §45) as of this pass. // Logic moved verbatim into EntitlementBLL.Handlers.SchedulerTasksBLL, now dispatched via // GET /Entitlement/Scheduler/RunKillSwitchMonitor / RunPayOrderReconciliation instead of a // per-module in-memory Quartz instance. }); builder.Services.AddQuartzHostedService(opt => opt.WaitForJobsToComplete = true); // ProvisioningHub — real-time client-onboarding status push (tracker §37 Decision 2). Options // mirror CLAUDE.md's SignalR registration template / DXPSL's own identical AddSignalR call — // this is EntitlementSL's first SignalR hub. builder.Services.AddSignalR(options => { options.EnableDetailedErrors = builder.Environment.IsDevelopment(); options.MaximumReceiveMessageSize = 32 * 1024; options.ClientTimeoutInterval = TimeSpan.FromSeconds(60); options.KeepAliveInterval = TimeSpan.FromSeconds(15); }); builder.Services.AddScoped(); var endpointAssemblies = new[] { Assembly.Load("EntitlementSL"), entitlementBllAssembly, entitlementDalAssembly, Assembly.Load("GB5Shared") }; builder.Services.AddFastEndpoints(o => { o.Assemblies = endpointAssemblies; }); builder.Services.SwaggerDocument(o => { o.DocumentSettings = s => { s.Title = "GB5 Entitlement API — Licensing / Subscription / Feature-Flag Platform"; s.Version = "v1"; }; o.EnableJWTBearerAuth = false; o.ShortSchemaNames = true; }); var app = builder.Build(); // Log the resolved deployment tier loudly — a misconfigured box silently falling through to // Live's un-suffixed connection names is the one real risk this abstraction doesn't otherwise // guard against (see GB5Shared/Deployment). app.Logger.LogInformation("GB5 deployment tier: {Tier}", app.Services.GetRequiredService().TierCode); app.UseForwardedHeaders(); app.UseResponseCompression(); app.UseMiddleware(); app.UseMiddleware(); // After UseForwardedHeaders (so partitioning sees the real client IP, not a proxy's) and before // UseAuthentication/UseFastEndpoints (RequireRateLimiting is enforced at the endpoint-execution // stage) — matches GB5Shared.RateLimit.GB5RateLimitExtensions' own documented ordering. app.UseRateLimiter(); // Must run before UseFastEndpoints — Roles()/AuthSchemes() on ClientAuth endpoints need // HttpContext.User populated by the time the endpoint executes. app.UseAuthentication(); app.UseAuthorization(); app.UseFastEndpoints(c => { c.Serializer.Options.PropertyNamingPolicy = null; c.Serializer.Options.DictionaryKeyPolicy = null; c.Serializer.Options.AddGB5Converters(); }); app.UseOpenApi(); app.UseSwaggerUi(o => { o.Path = "/EntitlementDocumentation"; o.DocumentPath = "/swagger/{documentName}/swagger.json"; o.TransformToExternalPath = (internalUiRoute, _) => "/lic" + internalUiRoute; }); app.UseCloudEvents(); app.MapSubscribeHandler(); app.MapControllers(); app.MapHub("/hubs/provisioning"); app.MapGet("/", () => "Hello from GB5 Entitlement .NET 9 API!"); app.Run();