- ASP.NET Core 9 Razor Pages + Minimal API hybrid - 14 validators (CPF, CEP, CNPJ, email, phone, name, yes-no, birthdate, handoff, cancel-intent, company-name, plate-br, postal-code, validate_reply) - OAuth login (Google, Microsoft, GitHub) + cookie auth - MongoDB usage tracking + CEP cache collection - Stripe checkout with inline PriceData (no Price IDs) - MCP server for Claude Code / Cursor integration - Playground (10 calls/IP/day, no auth) - Docs: Quickstart, API Reference, N8N, MCP, Créditos, Erros, Fluxos - Credit system: 3 cr standard validators, 5 cr validate_reply - SmartSuggestion: contextual re-ask via IA when obtained=false - Per-IP rate limiting + daily cap + shared-IP abuse detection - Lightbox for comic images - Validadores page split: Brasileiros / Universais + Em breve Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
267 lines
13 KiB
C#
267 lines
13 KiB
C#
using AspNet.Security.OAuth.GitHub;
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
using Microsoft.AspNetCore.Authentication.Google;
|
|
using Microsoft.AspNetCore.Authentication.MicrosoftAccount;
|
|
using MongoDB.Driver;
|
|
using Nalu.Web.Data;
|
|
using Nalu.Web.Data.Repositories;
|
|
using Nalu.Web.Endpoints;
|
|
using Nalu.Web.Enrichers;
|
|
using Nalu.Web.Infrastructure;
|
|
using Nalu.Web.Mcp;
|
|
using Nalu.Web.PostProcessors;
|
|
using Nalu.Web.Services;
|
|
using Nalu.Web.Services.LlmRouter;
|
|
using Scalar.AspNetCore;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
// ── Razor Pages (site) ───────────────────────────────────────────────────────
|
|
builder.Services.AddRazorPages();
|
|
|
|
// ── OpenAPI / Scalar ──────────────────────────────────────────────────────────
|
|
builder.Services.AddOpenApi("v1", options =>
|
|
{
|
|
options.AddDocumentTransformer((doc, _, _) =>
|
|
{
|
|
doc.Info.Title = "NALU AI API";
|
|
doc.Info.Version = "v1";
|
|
doc.Info.Description = "Natural Language Understanding — extrai intenções reais de diálogos agente/usuário.";
|
|
return Task.CompletedTask;
|
|
});
|
|
});
|
|
|
|
// ── MongoDB ───────────────────────────────────────────────────────────────────
|
|
builder.Services.AddSingleton<IMongoClient>(sp =>
|
|
{
|
|
var connStr = builder.Configuration.GetConnectionString("MongoDB") ?? string.Empty;
|
|
if (string.IsNullOrWhiteSpace(connStr)) return null!;
|
|
|
|
var settings = MongoClientSettings.FromConnectionString(
|
|
connStr + (connStr.Contains('?') ? "&" : "?") +
|
|
"maxPoolSize=200&minPoolSize=10&maxIdleTimeMS=30000");
|
|
|
|
return new MongoClient(settings);
|
|
});
|
|
|
|
builder.Services.AddSingleton<MongoDbContext>();
|
|
|
|
// ── Repositories ──────────────────────────────────────────────────────────────
|
|
builder.Services.AddSingleton<ApiKeyRepository>();
|
|
builder.Services.AddSingleton<UserRepository>();
|
|
builder.Services.AddSingleton<UsageRepository>();
|
|
builder.Services.AddSingleton<SubscriptionRepository>();
|
|
builder.Services.AddSingleton<WebhookEventRepository>();
|
|
|
|
// ── Authentication ───────────────────────────────────────────────────────────
|
|
// Site = Cookie (Razor Pages). API = Bearer API key (/v1/*). DO NOT mix.
|
|
builder.Services.AddAuthentication(options =>
|
|
{
|
|
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
|
options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
|
options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
|
})
|
|
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
|
|
{
|
|
options.LoginPath = "/login";
|
|
options.LogoutPath = "/auth/logout";
|
|
options.Cookie.HttpOnly = true;
|
|
options.Cookie.SameSite = SameSiteMode.Lax;
|
|
options.ExpireTimeSpan = TimeSpan.FromDays(30);
|
|
options.SlidingExpiration = true;
|
|
})
|
|
// Temporary cookie for external OAuth handshake
|
|
.AddCookie("ExternalCookie", options =>
|
|
{
|
|
options.Cookie.Name = "nalu.external";
|
|
options.ExpireTimeSpan = TimeSpan.FromMinutes(10);
|
|
})
|
|
// API key scheme — used explicitly by /v1/* endpoints
|
|
.AddScheme<AuthenticationSchemeOptions, NaluAuthHandler>(ApiKeyAuthScheme.Name, null)
|
|
.AddGoogle(GoogleDefaults.AuthenticationScheme, options =>
|
|
{
|
|
options.ClientId = builder.Configuration["OAuth:Google:ClientId"] ?? string.Empty;
|
|
options.ClientSecret = builder.Configuration["OAuth:Google:ClientSecret"] ?? string.Empty;
|
|
options.CallbackPath = "/signin-google";
|
|
options.SignInScheme = "ExternalCookie";
|
|
options.SaveTokens = false;
|
|
})
|
|
.AddMicrosoftAccount(MicrosoftAccountDefaults.AuthenticationScheme, options =>
|
|
{
|
|
options.ClientId = builder.Configuration["OAuth:Microsoft:ClientId"] ?? string.Empty;
|
|
options.ClientSecret = builder.Configuration["OAuth:Microsoft:ClientSecret"] ?? string.Empty;
|
|
options.CallbackPath = "/signin-microsoft";
|
|
options.SignInScheme = "ExternalCookie";
|
|
options.SaveTokens = false;
|
|
})
|
|
.AddGitHub(GitHubAuthenticationDefaults.AuthenticationScheme, options =>
|
|
{
|
|
options.ClientId = builder.Configuration["OAuth:GitHub:ClientId"] ?? string.Empty;
|
|
options.ClientSecret = builder.Configuration["OAuth:GitHub:ClientSecret"] ?? string.Empty;
|
|
options.CallbackPath = "/signin-github";
|
|
options.SignInScheme = "ExternalCookie";
|
|
options.Scope.Add("user:email");
|
|
options.SaveTokens = false;
|
|
});
|
|
|
|
builder.Services.AddAuthorization(options =>
|
|
{
|
|
// API endpoints — must present a valid Bearer API key
|
|
options.AddPolicy("ApiKey", policy =>
|
|
policy.AddAuthenticationSchemes(ApiKeyAuthScheme.Name)
|
|
.RequireAuthenticatedUser());
|
|
});
|
|
|
|
// ── Session (required for OAuth state) ───────────────────────────────────────
|
|
builder.Services.AddDistributedMemoryCache();
|
|
builder.Services.AddSession(options =>
|
|
{
|
|
options.Cookie.HttpOnly = true;
|
|
options.Cookie.IsEssential = true;
|
|
options.IdleTimeout = TimeSpan.FromMinutes(10);
|
|
});
|
|
|
|
// ── Caching ──────────────────────────────────────────────────────────────────
|
|
builder.Services.AddMemoryCache();
|
|
|
|
// ── HTTP clients ─────────────────────────────────────────────────────────────
|
|
builder.Services.AddHttpClient<GroqClient>(client =>
|
|
{
|
|
var baseUrl = builder.Configuration["Groq:BaseUrl"] ?? "https://api.groq.com/openai/v1";
|
|
client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + '/');
|
|
client.DefaultRequestHeaders.Add(
|
|
"Authorization",
|
|
$"Bearer {builder.Configuration["Groq:ApiKey"]}");
|
|
client.Timeout = TimeSpan.FromSeconds(30);
|
|
});
|
|
|
|
// ── LLM Router (Groq → OpenRouter → Google AI) ───────────────────────────────
|
|
builder.Services.AddHttpClient<GroqProvider>(client =>
|
|
{
|
|
var baseUrl = builder.Configuration["Groq:BaseUrl"] ?? "https://api.groq.com/openai/v1";
|
|
client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + '/');
|
|
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {builder.Configuration["Groq:ApiKey"]}");
|
|
client.Timeout = TimeSpan.FromSeconds(30);
|
|
});
|
|
|
|
builder.Services.AddHttpClient<OpenRouterProvider>(client =>
|
|
{
|
|
var baseUrl = builder.Configuration["OpenRouter:BaseUrl"] ?? "https://openrouter.ai/api/v1";
|
|
client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + '/');
|
|
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {builder.Configuration["OpenRouter:ApiKey"]}");
|
|
client.DefaultRequestHeaders.Add("HTTP-Referer", "https://naluai.com");
|
|
client.DefaultRequestHeaders.Add("X-Title", "NALU AI");
|
|
client.Timeout = TimeSpan.FromSeconds(30);
|
|
});
|
|
|
|
builder.Services.AddHttpClient<GoogleAiProvider>(client =>
|
|
{
|
|
var baseUrl = builder.Configuration["GoogleAi:BaseUrl"] ?? "https://generativelanguage.googleapis.com/v1beta/openai/";
|
|
client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + '/');
|
|
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {builder.Configuration["GoogleAi:ApiKey"]}");
|
|
client.Timeout = TimeSpan.FromSeconds(30);
|
|
});
|
|
|
|
builder.Services.AddSingleton<ILlmRouter>(sp =>
|
|
{
|
|
var providers = new List<ILlmProvider>
|
|
{
|
|
sp.GetRequiredService<GroqProvider>(),
|
|
sp.GetRequiredService<OpenRouterProvider>(),
|
|
sp.GetRequiredService<GoogleAiProvider>()
|
|
};
|
|
return new LlmRouter(providers, sp.GetRequiredService<ILogger<LlmRouter>>());
|
|
});
|
|
|
|
// ViaCEP enricher — no fixed base address (calls multiple URLs)
|
|
builder.Services.AddHttpClient<ViaCepEnricher>(client =>
|
|
{
|
|
client.Timeout = TimeSpan.FromSeconds(10);
|
|
client.DefaultRequestHeaders.Add("User-Agent", "nalu-ai/1.0");
|
|
});
|
|
builder.Services.AddTransient<IEnricher>(sp => sp.GetRequiredService<ViaCepEnricher>());
|
|
|
|
// ── Post-processors ───────────────────────────────────────────────────────────
|
|
builder.Services.AddSingleton<IPostProcessor, CapitalizeProperName>();
|
|
builder.Services.AddSingleton<IPostProcessor, RemoveTitles>();
|
|
builder.Services.AddSingleton<IPostProcessor, ValidateCpfDigit>();
|
|
builder.Services.AddSingleton<IPostProcessor, FormatPhone>();
|
|
builder.Services.AddSingleton<IPostProcessor, FormatCep>();
|
|
builder.Services.AddSingleton<IPostProcessor, CorrectEmailTypos>();
|
|
builder.Services.AddSingleton<IPostProcessor, NormalizePostalCode>();
|
|
builder.Services.AddSingleton<IPostProcessor, ParseDate>();
|
|
builder.Services.AddSingleton<IPostProcessor, CalculateAge>();
|
|
builder.Services.AddSingleton<IPostProcessor, ValidateCnpjDigit>();
|
|
builder.Services.AddSingleton<IPostProcessor, FormatPlate>();
|
|
builder.Services.AddSingleton<IPostProcessor, SelectHandoffSuggestion>();
|
|
builder.Services.AddSingleton<IPostProcessor, SelectCancelSuggestion>();
|
|
|
|
// ── Domain services ──────────────────────────────────────────────────────────
|
|
builder.Services.AddScoped<UserService>();
|
|
builder.Services.AddSingleton<ValidatorLoader>();
|
|
builder.Services.AddSingleton<DeterministicLayer>();
|
|
builder.Services.AddSingleton<PostProcessorRegistry>();
|
|
builder.Services.AddSingleton<SuggestionBuilder>();
|
|
builder.Services.AddSingleton<CacheService>();
|
|
builder.Services.AddSingleton<AuthService>();
|
|
builder.Services.AddSingleton<RateLimitService>();
|
|
builder.Services.AddSingleton<CreditService>();
|
|
// EnrichmentService is Scoped because ViaCepEnricher is Transient (HttpClient lifecycle)
|
|
builder.Services.AddScoped<EnrichmentService>();
|
|
builder.Services.AddScoped<LlmExtractionService>();
|
|
builder.Services.AddScoped<ExtractionPipeline>();
|
|
builder.Services.AddScoped<ReplyService>();
|
|
|
|
// ── MCP server ────────────────────────────────────────────────────────────────
|
|
builder.Services.AddSingleton<McpServer>();
|
|
|
|
// ── App ───────────────────────────────────────────────────────────────────────
|
|
var app = builder.Build();
|
|
|
|
// Initialize MongoDB indexes on startup
|
|
var mongo = app.Services.GetRequiredService<MongoDbContext>();
|
|
await mongo.InitializeAsync();
|
|
|
|
app.UseSession();
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
|
|
app.MapOpenApi();
|
|
app.MapScalarApiReference(options =>
|
|
{
|
|
options.Title = "NALU AI";
|
|
options.DefaultHttpClient = new(ScalarTarget.Http, ScalarClient.HttpClient);
|
|
});
|
|
|
|
app.MapRazorPages();
|
|
app.MapExtractEndpoints();
|
|
app.MapValidatorsEndpoints();
|
|
|
|
// ── Auth endpoints ────────────────────────────────────────────────────────────
|
|
app.MapGet("/auth/logout", async (HttpContext ctx) =>
|
|
{
|
|
await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
|
return Results.Redirect("/");
|
|
}).AllowAnonymous();
|
|
|
|
// ── Health check ──────────────────────────────────────────────────────────────
|
|
app.MapGet("/health", () => Results.Ok(new { status = "ok", ts = DateTime.UtcNow }))
|
|
.WithTags("Health")
|
|
.WithSummary("Health check")
|
|
.AllowAnonymous();
|
|
|
|
// ── MCP endpoint ──────────────────────────────────────────────────────────────
|
|
app.MapPost("/mcp", async (HttpContext ctx, McpServer mcp, CancellationToken ct) =>
|
|
await mcp.HandleAsync(ctx, ct))
|
|
.RequireAuthorization("ApiKey")
|
|
.WithName("McpEndpoint")
|
|
.WithSummary("MCP Server (JSON-RPC 2.0 / Streamable HTTP)")
|
|
.WithTags("MCP")
|
|
.WithOpenApi();
|
|
|
|
app.Run();
|
|
|
|
// Exposed for WebApplicationFactory in integration tests
|
|
public partial class Program { }
|