using System.IO.Compression; using System.Text.Json; using AutopilotMonitor.Functions.DataAccess; using AutopilotMonitor.Functions.Functions.Config; using AutopilotMonitor.Functions.Functions.Ingest; using AutopilotMonitor.Functions.Functions.Sessions; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Middleware; using AutopilotMonitor.Functions.Services; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.ResponseCompression; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Builder; using Microsoft.Azure.Functions.Worker.Middleware; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Identity.Web; // First statement on purpose: BackendStartupMs measures from here (see StartupClock). var startupClock = StartupClock.StartNow(); var builder = FunctionsApplication.CreateBuilder(args); builder.ConfigureFunctionsWebApplication(); // Register middleware pipeline (Azure Functions .NET 8 isolated worker pattern) // Order matters: request telemetry (wraps all) → correlation ID → global exception handler → JWT authentication (401) → policy enforcement (403) builder.UseMiddleware(); builder.UseMiddleware(); builder.UseMiddleware(); // Stamp static security-baseline headers (X-Content-Type-Options, Referrer-Policy, // X-Frame-Options) on every response. Unconditional, runs early so even short-circuit // 401/403/500 responses from downstream middleware inherit the headers. builder.UseMiddleware(); // Stamp Cache-Control: no-store on credential/identity endpoints before next() so // short-circuit responses (401/403/500) from downstream middleware still inherit the // header. Allowlist lives inside NoStoreCacheMiddleware. builder.UseMiddleware(); builder.UseMiddleware(); builder.UseMiddleware(); builder.UseMiddleware(); builder.UseMiddleware(); // MCP quota (daily/monthly budget on top of the per-minute rate limit): only requests marked // X-Client-Source: mcp with an authenticated principal are checked; GA exempt; fail-open on // counter errors, fail-closed on plan resolution. See McpQuotaEnforcementMiddleware. builder.UseMiddleware(); // After policy enforcement (RequestContext resolved): record best-effort "last seen" presence for // authenticated web users so a Global Admin can see who is active right now. Throttled + fail-open. builder.UseMiddleware(); // Configure JSON serialization to the wire settings (camelCase, absent-when-null, string enums). // Single source: ApiJsonOptions — test harnesses build their serializer from the same settings. builder.Services.Configure(ApiJsonOptions.Apply); builder.Services .AddApplicationInsightsTelemetryWorkerService() .ConfigureFunctionsApplicationInsights(); // L4 ATTEMPT REVERTED 2026-06-09 (deploy-verified ineffective): tried to exclude Request;Event // from worker-side adaptive sampling via EnableAdaptiveSampling=false + a manual // Configure { DefaultTelemetrySink…UseAdaptiveSampling(excludedTypes) }. // In the Functions isolated worker that classic-ASP.NET pattern does NOT take effect — live data // showed the enriched worker request copy (Source=WorkerMiddleware) getting sampled to ~63% while // the redundant host request item (Source="") stayed fully billed. Net worse (lossy enriched copy, // unchanged host duplicate). The host request item is emitted by the HOST process and cannot be // dropped from worker code or by host.json "Host.Results" level. Re-attempt only with a mechanism // verified against the isolated-worker telemetry pipeline — do not re-add the manual chain. // 2026-08-23: solved differently — RequestTelemetryMiddleware sets SamplingPercentage=100 on its // own item (per-item sampling bypass), and the host duplicate is removed by a workspace DCR // transformation on AppRequests (keeps rows with Properties.Source=='WorkerMiddleware' plus // non-HTTP host rows with empty Url, i.e. timer/queue invocations). See internal/docs/backend/telemetry-ingest-shaping.md. // Drop successful Azure Storage dependencies (Table/Queue/Blob) from the worker telemetry // pipeline to curb AppDependencies ingestion cost — this backend is storage-I/O heavy and those // rows are high-volume, low-value. Failed storage calls, slow successes (>= 2 s, stamped // SlowStorage=true) and all non-storage dependencies (HTTP/Graph/SQL/SignalR) are preserved. // See StorageDependencyFilterProcessor for the contract. builder.Services.AddApplicationInsightsTelemetryProcessor(); // Pre-aggregated storage metrics (StorageCasConflict by operation/table/outcome): metrics bypass // the worker's adaptive sampling and the dependency filter, so ETag conflicts stay visible as a // trend although their 412/409 dependency rows are dropped. builder.Services.AddSingleton(); // Configure JWT Authentication for Multi-Tenant Azure AD builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddMicrosoftIdentityWebApi(options => { // Configure JWT Bearer options if needed options.Events = new JwtBearerEvents { OnAuthenticationFailed = context => { var logger = context.HttpContext.RequestServices.GetRequiredService().CreateLogger("Auth"); logger.LogWarning("Authentication failed: {Error}", context.Exception?.Message); return Task.CompletedTask; }, OnTokenValidated = context => { var logger = context.HttpContext.RequestServices.GetRequiredService().CreateLogger("Auth"); var claims = context.Principal?.Claims; var tenantId = claims?.FirstOrDefault(c => c.Type == "tid")?.Value; logger.LogDebug("Token validated for tenant: {TenantId}", tenantId); return Task.CompletedTask; } }; }, options => { // Multi-Tenant Configuration // Fully qualified: Microsoft.Identity.Web also exports a 'Constants' type. options.Instance = AutopilotMonitor.Shared.Constants.EntraLoginBaseUrl + "/"; options.TenantId = "organizations"; // Accept tokens from any Azure AD tenant options.ClientId = builder.Configuration["EntraId:ClientId"]; // Token validation parameters options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, ValidIssuers = new[] { AutopilotMonitor.Shared.Constants.EntraLoginBaseUrl + "/organizations/v2.0", "https://sts.windows.net/{tenantid}/" }, // Full audience trust set — MUST match AuthenticationMiddleware's per-request set // (primary + legacy + additional client ids, each in bare and api:// form). Built via // the SAME shared helpers so the two lists cannot drift; a single-audience list here // would 401 legacy-app tokens on [Authorize]-gated paths during the dual app-reg window. ValidAudiences = AutopilotMonitor.Functions.Middleware.AuthenticationMiddleware.BuildValidAudiences( AutopilotMonitor.Functions.Middleware.AuthenticationMiddleware.ResolveConfiguredClientIds( builder.Configuration["EntraId:ClientId"], AutopilotMonitor.Functions.Middleware.AuthenticationMiddleware.CombineAdditionalClientIdSources( builder.Configuration["EntraId:LegacyClientId"], builder.Configuration["EntraId:AdditionalClientIds"]), out _)) }; }); builder.Services.AddAuthorization(); // Enable ASP.NET Core integration for authentication builder.Services.AddHttpContextAccessor(); // HTTP compression — bidirectional gzip for bandwidth-sensitive agent links. // UseResponseCompression: backend → agent (config, ingest ack, ...). Triggered by Accept-Encoding. // UseRequestDecompression: agent → backend (ingest NDJSON). Triggered by Content-Encoding. // Registered via IStartupFilter because FunctionsApplication.CreateBuilder's Build() returns IHost, // not WebApplication — we can't call app.UseXxx() directly. builder.Services.AddResponseCompression(options => { options.EnableForHttps = true; options.Providers.Add(); options.MimeTypes = ResponseCompressionDefaults.MimeTypes .Concat(new[] { "application/json", "application/x-ndjson" }); }); builder.Services.Configure(o => o.Level = CompressionLevel.Fastest); builder.Services.AddRequestDecompression(); builder.Services.AddTransient(); // Register our services builder.Services.AddMemoryCache(); builder.Services.AddHttpClient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Shared QueueClient bootstrap (code-quality audit D2): single Managed-Identity/connection-string // resolution consumed by every queue producer / worker / probe. builder.Services.AddSingleton(); // Cascade-deletion read surface implemented by the TableStorageService partial (PR1). // No production caller in PR1; producer (PR3) + worker (PR4) consume it later. builder.Services.AddSingleton( sp => sp.GetRequiredService()); builder.Services.AddSingleton(); // PR3: cascade-delete guard (writer-block invariant) + producer (CAS state-machine + queue enqueue). // PR5 wires the producer into DeleteSessionFunction (admin-delete dispatcher) via the // ISessionDeletionEnqueuer interface; PR6 will wire it into the dedicated // SessionDeletionMaintenanceFunction (retention fanout). builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton( sp => sp.GetRequiredService()); // PR4b: cascade-delete restore endpoint (full + partial-poisoned-recovery). Consumed by // RestoreSessionFunction; GA-only via EndpointAccessPolicyCatalog. No hosted-service registration // needed — the function is HTTP-triggered. builder.Services.AddSingleton(); // PR4: cascade-delete worker pipeline. Verifier + Handler are pure DI plumbing; Worker is the // queue poll-loop. Registered as HostedService in PR5 (the producer-wiring PR) so the queue // drains as soon as the flag-gated DeleteSessionFunction starts producing envelopes; without // this the session-deletion queue would back up. builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); // PR6: cascade-delete maintenance subsystem — dedicated 12h timer with watchdog OpsEvents, // retention fanout (V2 + legacy), manifest-blob TTL sweep, stale-Preparing GC, and stranded- // Queued detection. Independent cadence + kill-switch from the generic 2h Maintenance timer. builder.Services.AddSingleton(); // Manual-trigger path: blob-lease lock store (serializes timer vs manual runs), fail-hard // queue producer for POST /api/global/session-deletions/maintenance/trigger, and the // BackgroundService worker that consumes trigger envelopes. The maintenance Function class is // registered explicitly so the worker can invoke the same RunCoreAsync body the timer uses. builder.Services.AddSingleton(); builder.Services.AddSingleton< AutopilotMonitor.Functions.Services.Deletion.ISessionDeletionMaintenanceTriggerProducer, AutopilotMonitor.Functions.Services.Deletion.AzureQueueSessionDeletionMaintenanceTriggerProducer>(); builder.Services.AddSingleton(); builder.Services.AddHostedService< AutopilotMonitor.Functions.Services.Deletion.SessionDeletionMaintenanceQueueWorker>(); // Tenant-offboarding cascade worker (Plan Rev 9). Consumes per-tenant envelopes off // `tenant-offboarding`, drives the §3 Phase 2 sequence (cascade enqueue → drain → SafeWipe → // blob wipe → tenant config delete), and lands the audit row under AuditGlobalTenantId. // OffboardingFilters is a static helper — no DI registration needed. builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton< AutopilotMonitor.Functions.Services.Offboarding.IOffboardingExpectationsStore, AutopilotMonitor.Functions.Services.Offboarding.BlobOffboardingExpectationsStore>(); builder.Services.AddSingleton< AutopilotMonitor.Functions.Services.Offboarding.IDeletionProgressDrainProbe, AutopilotMonitor.Functions.Services.Offboarding.DeletionProgressDrainProbe>(); builder.Services.AddSingleton< AutopilotMonitor.Functions.Services.Offboarding.ITenantOffboardingEnqueuer, AutopilotMonitor.Functions.Services.Offboarding.AzureQueueTenantOffboardingEnqueuer>(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); // OffboardingMarkerCleanupFunction is TimerTrigger — auto-registered via the Functions host; // no AddHostedService call needed. DI for its dependencies comes from the singletons above. builder.Services.AddHostedService(); // Initialize all tables at startup builder.Services.AddSingleton(startupClock); builder.Services.AddHostedService(); // BackendStartupMs / BackendTableInitMs metrics on ApplicationStarted builder.Services.AddHostedService(); // ProcessStall event + metric: process-wide pauses attributed to GC / thread-pool starvation // Data Access Layer — repository interfaces backed by Table Storage. // To switch to Cosmos DB: replace AddTableStorageDataAccess() with AddCosmosDataAccess(). // To add event streaming: chain .AddEventStreaming() after this call. builder.Services.AddTableStorageDataAccess(); // Conferred Pro ("Pro (MSP)"): the read-time index the configuration loader projects from, and the // write side that stamps the retention grace anchor when a conferring delegation ends. builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Identity binding (tid + oid behind every cross-tenant-role UPN) — consulted by both role services. builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Shared activation path (whitelist add + auto-promote + welcome email) — used by the // Global Admin approve endpoint and the tenant auto-approve queue worker. builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Cached per-tenant evaluateOn trigger sets for the ingest-side interim analyze enqueue // (internal/docs/rules/analyze-rule-triggers.md). Singleton so the 5-min TTL cache is shared. builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHttpClient() .AddPolicyHandler((sp, _) => sp.GetRequiredService().ExternalDataApi); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton< AutopilotMonitor.Functions.Services.Monitoring.IAzureMonitorMetricsReader, AutopilotMonitor.Functions.Services.Monitoring.AzureMonitorMetricsReader>(); builder.Services.AddSingleton< AutopilotMonitor.Functions.Services.Monitoring.IPoisonQueueProbe, AutopilotMonitor.Functions.Services.Monitoring.AzurePoisonQueueProbe>(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Assume-breach observer on the policy middleware's deny path (GlobalAdminOnly 403 → Critical ops event). builder.Services.AddSingleton(); // Operator KQL proxy (global/raw/logs): the three telemetry stores it can reach, the managed-identity // credential as an injectable seam, and a client WITHOUT its own timeout — the per-request budget // (CancellationTokenSource + Prefer: wait) is the only clock. builder.Services.AddSingleton(); builder.Services.AddSingleton(_ => new Azure.Identity.DefaultAzureCredential()); builder.Services.AddHttpClient(LogQuerySourceCatalog.HttpClientName, c => c.Timeout = Timeout.InfiniteTimeSpan); // IME pattern-drift loop: folds ime_pattern_hits into ImePatternStats and raises // ImePatternDriftSuspected; singleton because it caches the stats snapshot for evaluation. builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Queue producers + handlers. Consumers are [QueueTrigger] functions under Functions/Queue/ // on the Constants.QueueNames.TriggerConnection connection (host-resolved app setting // `DataStorage__queueServiceUri`; see host.json extensions.queues). Stateless queue consumers // are host-managed so the Flex scale controller wakes an instance for a message and renews the // lease; only the cascade workers with lease/CAS state machines stay BackgroundServices. // Auto-analyze fan-out at session end. Replaces the previous in-function fire-and-forget // Task.Run that ran the rule engine after enrollment_complete / enrollment_failed / async // vulnerability correlation — Functions scale-in could kill the Task.Run mid-flight, leaving // rule results un-persisted (manual "Analyze Now" was the only recovery). // Consumer = Functions/Queue/AnalyzeOnEnrollmentEndQueueFunction. builder.Services.AddSingleton< AutopilotMonitor.Functions.Services.Analyze.IAnalyzeOnEnrollmentEndProducer, AutopilotMonitor.Functions.Services.Analyze.AzureQueueAnalyzeOnEnrollmentEndProducer>(); builder.Services.AddSingleton< AutopilotMonitor.Functions.Services.Analyze.AnalyzeOnEnrollmentEndHandler>(); // Vulnerability-correlate fan-out triggered by the shutdown software_inventory_analysis // event. Replaces the in-function fire-and-forget Task.Run inside EventIngestProcessor. // Same producer + handler pattern as the analyze queue; consumer = // Functions/Queue/VulnerabilityCorrelateQueueFunction. Inventory loader is DI-shared with // the manual rescan endpoint (GetVulnerabilityReportFunction ?rescan=true). builder.Services.AddSingleton< AutopilotMonitor.Functions.Services.Vulnerability.IVulnerabilityCorrelateProducer, AutopilotMonitor.Functions.Services.Vulnerability.AzureQueueVulnerabilityCorrelateProducer>(); builder.Services.AddSingleton< AutopilotMonitor.Functions.Services.Vulnerability.IVulnerabilityInventoryLoader, AutopilotMonitor.Functions.Services.Vulnerability.VulnerabilityInventoryLoader>(); builder.Services.AddSingleton< AutopilotMonitor.Functions.Services.Vulnerability.VulnerabilityCorrelateHandler>(); // IME-installer archiving on first fleet-wide sighting of a new IME version (~monthly). // Producer sits in EventIngestProcessor's new-version continuation; the consumer // (Functions/Queue/ImeMsiArchiveQueueFunction) downloads the MSI from the CSP-reported // (allowlisted) URL into the permanent ime-archive container and merges the outcome onto the // ImeVersionHistory row. Typed HttpClient: no redirects // (SSRF posture — the allowlisted host must serve directly), long timeout owned by the // archiver's own CTS, transient retry via the shared external-data policy. builder.Services.AddHttpClient() .ConfigurePrimaryHttpMessageHandler(() => new System.Net.Http.SocketsHttpHandler { AllowAutoRedirect = false, ConnectTimeout = TimeSpan.FromSeconds(10), }) .ConfigureHttpClient(client => client.Timeout = Timeout.InfiniteTimeSpan) .AddPolicyHandler((sp, _) => sp.GetRequiredService().ExternalDataApi); builder.Services.AddSingleton< AutopilotMonitor.Functions.Services.Ime.IImeMsiArchiveProducer, AutopilotMonitor.Functions.Services.Ime.AzureQueueImeMsiArchiveProducer>(); // Delayed tenant auto-approve (public availability). Producer fires at first-login signup // with a ~1-minute visibility delay; the consumer (Functions/Queue/TenantAutoApproveQueueFunction) // activates the tenant via the shared TenantApprovalService only if // AdminConfiguration.AutoApproveNewTenants is enabled at processing time. Same producer + // handler pattern as the analyze queue above. builder.Services.AddSingleton< AutopilotMonitor.Functions.Services.Activation.ITenantAutoApproveEnqueuer, AutopilotMonitor.Functions.Services.Activation.AzureQueueTenantAutoApproveEnqueuer>(); builder.Services.AddSingleton< AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveHandler>(); // ── Critical-Table Backup (plan §PR1) ───────────────────────────────────────── // Lease-aware service registered separately from the timer/worker so the lease // boundary is testable in isolation. JobsRepository is the canonical persistence // for the 6-state machine. builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton< AutopilotMonitor.Functions.Services.Backup.ICriticalTableBackupService, AutopilotMonitor.Functions.Services.Backup.CriticalTableBackupService>(); builder.Services.AddSingleton(); // Producer = fail-hard SendMessage (HTTP trigger handles the rollback to Failed). builder.Services.AddSingleton< AutopilotMonitor.Functions.Services.Backup.Queue.ICriticalTableBackupProducer, AutopilotMonitor.Functions.Services.Backup.Queue.AzureQueueCriticalTableBackupProducer>(); // Worker = self-managed BackgroundService, not QueueTrigger: the lease/CAS state machine // (Running→Completed under lease, Failed only on poison-move) is a per-message lifecycle the // host cannot express. See QueuePollingWorkerBase for the two-class rule. builder.Services.AddHostedService< AutopilotMonitor.Functions.Services.Backup.Queue.CriticalTableBackupQueueWorker>(); // PR2: single-row restore. Both validators are pure / I/O-only; the restore // service is stateless (lease lifecycle is owned per-call). Singleton is safe. builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Programmatic SignalR push for background tasks (rule engine, vulnerability correlation) builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); // Vulnerability correlation services builder.Services.AddHttpClient() .AddPolicyHandler((sp, _) => sp.GetRequiredService().ExternalDataApi); // KevDataService keeps the hydrated catalog as instance state, but AddHttpClient registers T // transient: the cache warmer hydrated one instance while the correlation service and the sync // functions each held a cold one (audit 2026-09-05 F12). The typed-client registration stays for // the named handler pipeline; the singleton below is what every consumer resolves. builder.Services.AddHttpClient() .AddPolicyHandler((sp, _) => sp.GetRequiredService().ExternalDataApi) .SetHandlerLifetime(Timeout.InfiniteTimeSpan); builder.Services.AddSingleton(sp => new AutopilotMonitor.Functions.Services.Vulnerability.KevDataService( sp.GetRequiredService().CreateClient(nameof(AutopilotMonitor.Functions.Services.Vulnerability.KevDataService)), sp.GetRequiredService(), sp.GetRequiredService>())); builder.Services.AddHttpClient() .AddPolicyHandler((sp, _) => sp.GetRequiredService().ExternalDataApi); builder.Services.AddHttpClient() .AddPolicyHandler((sp, _) => sp.GetRequiredService().ExternalDataApi); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Hydrate MSRC + KEV in-memory caches from blob snapshots at app startup (fire-and-forget; // keeps cold-start fast for re-deploys, see VulnerabilityCacheWarmer for the contract). builder.Services.AddHostedService(); // Register agent Function classes so bootstrap wrappers can inject them for code reuse builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Graph add-on permission detection + script display-name resolution. // Detector parses the `roles` claim of the SP access token; Resolver fetches // Intune script display names from Graph beta, cached per tenant. builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHttpClient() .ConfigurePrimaryHttpMessageHandler(() => new System.Net.Http.SocketsHttpHandler { AllowAutoRedirect = false, ConnectTimeout = TimeSpan.FromSeconds(10), }) .ConfigureHttpClient(client => client.Timeout = TimeSpan.FromSeconds(15)) .AddPolicyHandler((sp, _) => sp.GetRequiredService().Notification); builder.Services.AddHttpClient() .AddPolicyHandler((sp, _) => sp.GetRequiredService().Notification); // Channel-level send API — routes each NotificationChannel to its transport (webhook renderer // vs. the platform Telegram bot). Transient: both transports are typed HttpClients. builder.Services.AddTransient(); // Durable channel notifications from the agent hot paths (ingest terminal alerts, hardware // rejection): the producer enqueues channel ids + alert, Functions/Queue/NotificationDispatchQueueFunction // re-resolves the channels from the tenant config and sends. Handler is transient because the // dispatcher it wraps is. builder.Services.AddSingleton< AutopilotMonitor.Functions.Services.Notifications.INotificationDispatchProducer, AutopilotMonitor.Functions.Services.Notifications.AzureQueueNotificationDispatchProducer>(); builder.Services.AddTransient(); // Transactional email (welcome / farewell). Typed client like the other outbound notifiers; // the provider is an implementation detail of EmailService + the Email:* settings. builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddHttpClient() .ConfigureHttpClient(client => client.Timeout = TimeSpan.FromSeconds(15)) .AddPolicyHandler((sp, _) => sp.GetRequiredService().Notification); builder.Services.AddTransient(sp => sp.GetRequiredService()); builder.Services.AddTransient(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton(); var app = builder.Build(); // Validate critical security configuration at startup var startupLogger = app.Services.GetRequiredService().CreateLogger("Startup"); var entraClientId = builder.Configuration["EntraId:ClientId"]; var entraClientSecret = builder.Configuration["EntraId:ClientSecret"]; if (string.IsNullOrEmpty(entraClientId)) startupLogger.LogWarning("EntraId:ClientId is not configured — JWT audience validation and Graph API calls will fail"); if (string.IsNullOrEmpty(entraClientSecret)) startupLogger.LogWarning("EntraId:ClientSecret is not configured — device validation via Graph API will fail at runtime"); // Dual app-registration window: the legacy pair must be set together — an id without a secret // breaks Graph for every legacy-homed tenant, a secret without an id is dead config. var entraLegacyClientId = builder.Configuration["EntraId:LegacyClientId"]; var entraLegacyClientSecret = builder.Configuration["EntraId:LegacyClientSecret"]; if (string.IsNullOrEmpty(entraLegacyClientId) != string.IsNullOrEmpty(entraLegacyClientSecret)) startupLogger.LogWarning( "EntraId:LegacyClientId and EntraId:LegacyClientSecret must be configured together — only one is set, legacy-homed tenants will fail Graph token acquisition"); // Log CORS configuration at startup so misconfigured origins are immediately visible // in the log stream. CORS is enforced by Azure infrastructure, not by function code, // so a blocked preflight never reaches the function worker and leaves no trace. var corsOrigins = builder.Configuration["Host:CORS"] // local.settings.json ?? builder.Configuration["WEBSITE_CORS_ALLOWED_ORIGINS"] // Azure App Settings ?? "(not configured - all cross-origin requests will be blocked!)"; var corsCredentials = builder.Configuration["Host:CORSCredentials"] ?? builder.Configuration["WEBSITE_CORS_SUPPORT_CREDENTIALS"] ?? "unknown"; startupLogger.LogInformation( "=== CORS CONFIG: AllowedOrigins={CorsOrigins} | SupportCredentials={CorsCredentials} ===", corsOrigins, corsCredentials); app.Run();