proposal-system/api/src/ProposalSystem.Api/Program.cs
Adam Moussa 80e36bfb9a Fix Phase 3 audit findings: polish, operational maturity, and remaining FIX items
Addresses 29 FIX-severity findings and accessibility/code-quality NITs
from the 2026-05-20 audit. Key changes:

- Add confirmation dialogs for Mark as Sent and Create Revision (FIX-17)
- Restrict S3 CORS from wildcard to specific origins (FIX-38)
- Add API Gateway throttling at 100 rps / 50 burst (FIX-39)
- Separate vendor upload from SQS extraction trigger (FIX-04/05)
- Copy TotalBidAmount on proposal revision (FIX-11)
- Add CI paths-ignore and concurrency group (FIX-47)
- Add post-deploy health check (FIX-46)
- Fix N+1 query, Guid.Empty FK, pagination bounds, role sync (FIX-01/02/07/09)
- Fix dashboard OOM, status transitions, audit error handling (FIX-03/06/12)
- Fix frontend date filters, error display, currency formatting (FIX-14/16/18-23)
- Remove AOSS dashboard public access, skip empty AI suggestions (FIX-41/45)
- Add aria-labels, document.title management, deduplicate constants
- Restrict CORS localhost to development, log invalid API key attempts

[skip deploy]
2026-05-20 19:35:13 -04:00

188 lines
6.1 KiB
C#

using System.Text;
using Amazon.S3;
using Amazon.SecretsManager;
using Amazon.SQS;
using FluentValidation;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using ProposalSystem.Api.Middleware;
using ProposalSystem.Api.Services;
using ProposalSystem.Application.Interfaces;
using ProposalSystem.Application.Validators;
using ProposalSystem.Infrastructure.Data;
using ProposalSystem.Infrastructure.Services;
var builder = WebApplication.CreateBuilder(args);
// Dev mode flag (read early for conditional setup)
var devMode = builder.Configuration.GetValue<bool>("Auth:DevMode");
// AWS SDK clients (skip in dev mode — no real AWS credentials needed)
if (!devMode)
{
builder.Services.AddDefaultAWSOptions(builder.Configuration.GetAWSOptions());
builder.Services.AddAWSService<IAmazonS3>();
builder.Services.AddAWSService<IAmazonSQS>();
builder.Services.AddAWSService<IAmazonSecretsManager>();
}
// Database
var dbSecretArn = builder.Configuration["DB_SECRET_ARN"];
if (!string.IsNullOrEmpty(dbSecretArn))
{
var smClient = new AmazonSecretsManagerClient();
var connectionString = SecretsManagerConnectionString.ResolveAsync(smClient, dbSecretArn).GetAwaiter().GetResult();
builder.Services.AddDbContext<ProposalDbContext>(options =>
options.UseNpgsql(connectionString));
}
else
{
builder.Services.AddDbContext<ProposalDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
}
// Internal API key (for Lambda-to-API calls)
var internalApiKeySecretArn = builder.Configuration["INTERNAL_API_KEY_SECRET_ARN"];
if (!string.IsNullOrEmpty(internalApiKeySecretArn))
{
var smClient = new AmazonSecretsManagerClient();
var secretResponse = smClient.GetSecretValueAsync(new Amazon.SecretsManager.Model.GetSecretValueRequest
{
SecretId = internalApiKeySecretArn,
}).GetAwaiter().GetResult();
builder.Configuration["INTERNAL_API_KEY"] = secretResponse.SecretString;
}
// Authentication
var cognitoAuthority = builder.Configuration["Auth:Authority"];
if (!string.IsNullOrEmpty(cognitoAuthority))
{
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = cognitoAuthority;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
ValidateIssuer = true,
ValidateAudience = false,
ValidateLifetime = true,
RoleClaimType = "cognito:groups",
};
});
}
else if (devMode)
{
var devSigningKey = builder.Configuration["Auth:DevSigningKey"]!;
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(devSigningKey)),
ValidateIssuer = true,
ValidIssuer = "proposal-system-dev",
ValidateAudience = true,
ValidAudience = "proposal-system-dev",
ValidateLifetime = true,
RoleClaimType = "cognito:groups",
};
});
}
else
{
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer();
}
builder.Services.AddAuthorization();
// Services
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
builder.Services.AddScoped<IProposalService, ProposalService>();
builder.Services.AddScoped<ILineItemService, LineItemService>();
builder.Services.AddScoped<ICustomerService, CustomerService>();
builder.Services.AddScoped<IAuditService, AuditService>();
builder.Services.AddScoped<IProposalNumberGenerator, ProposalNumberGenerator>();
if (devMode)
builder.Services.AddScoped<IS3Service, DevS3Service>();
else
builder.Services.AddScoped<IS3Service, S3Service>();
builder.Services.AddScoped<ISimilarProposalService, SimilarProposalService>();
var jobsQueueUrl = builder.Configuration["JOBS_QUEUE_URL"] ?? "";
if (string.IsNullOrEmpty(jobsQueueUrl))
{
builder.Services.AddScoped<IJobPublisher, NoOpJobPublisher>();
}
else
{
builder.Services.AddScoped<IJobPublisher>(sp =>
{
var sqsClient = sp.GetRequiredService<IAmazonSQS>();
return new SqsJobPublisher(sqsClient, jobsQueueUrl);
});
}
// HTTP client for Cognito token exchange
builder.Services.AddHttpClient();
// Validation
builder.Services.AddValidatorsFromAssemblyContaining<CreateProposalValidator>();
// Controllers
builder.Services.AddControllers(options =>
{
options.Filters.Add<ValidationFilter>();
}).AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
});
// Middleware
builder.Services.AddTransient<GlobalExceptionHandler>();
// Health checks
builder.Services.AddHealthChecks()
.AddDbContextCheck<ProposalDbContext>();
// CORS
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
var origins = new List<string> { "https://proposals.seahaven.com" };
if (builder.Environment.IsDevelopment())
origins.Add("http://localhost:5173");
policy.WithOrigins(origins.ToArray())
.AllowAnyMethod()
.AllowAnyHeader();
});
});
// Lambda hosting
builder.Services.AddAWSLambdaHosting(LambdaEventSource.HttpApi);
var app = builder.Build();
app.UseMiddleware<GlobalExceptionHandler>();
app.UseCors();
app.UseMiddleware<InternalApiKeyMiddleware>();
app.UseAuthentication();
app.UseAuthorization();
app.Use(async (context, next) =>
{
if (context.User.Identity?.IsAuthenticated == true)
{
var userService = context.RequestServices.GetRequiredService<ICurrentUserService>();
await userService.ResolveAsync();
}
await next();
});
app.MapControllers();
app.MapHealthChecks("/api/health");
app.Run();