using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using WxAgent.Core; namespace WxAgent.Service; public static class ServiceHost { public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) { UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow }; public static readonly JsonSerializerOptions ConfigurationJson = new(Json) { UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip }; public static WebApplication Build(ServiceOptions options, IAgentBackend backend, Action? configure = null, string? logPath = null) { var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [], WebRootPath = Path.Combine(AppContext.BaseDirectory, "wwwroot") }); var runtimeLogPath = logPath ?? Path.Combine(AppContext.BaseDirectory, "wxagent.log"); var fileLogger = new FileLoggerProvider(runtimeLogPath); builder.Logging.ClearProviders(); builder.Logging.AddProvider(fileLogger); try { options.Validate(); } catch (Exception exception) { RuntimeLog.Append(runtimeLogPath, LogLevel.Error, "WxAgent.Service", "Service configuration validation failed.", exception); fileLogger.Dispose(); throw; } RuntimeLog.Append(runtimeLogPath, LogLevel.Information, "WxAgent.Service", $"Service configured for {options.ListenUrl}."); builder.WebHost.UseUrls(options.ListenUrl); builder.WebHost.ConfigureKestrel(k => k.Limits.MaxRequestBodySize = 1024 * 1024); // Keep operational logs beside the executable; credentials, headers and request bodies are not logged. builder.Logging.SetMinimumLevel(LogLevel.Information); builder.Logging.AddFilter("Microsoft.AspNetCore.Hosting.Diagnostics", LogLevel.None); builder.Logging.AddFilter("Microsoft.AspNetCore.Routing.EndpointMiddleware", LogLevel.None); builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow); builder.Services.AddSingleton(options); builder.Services.AddSingleton(backend); if (backend is IRemoteDataCollector dataCollector) builder.Services.AddSingleton(dataCollector); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(p => p.GetRequiredService()); if (options.Remote is not null || !string.IsNullOrWhiteSpace(options.RemoteConfigurationFile)) builder.Services.AddSingleton(new RemoteEventQueue(Path.Combine(options.DataDirectory, "remote-event-queue.json"))); builder.Services.AddHostedService(); if (options.Remote is not null || !string.IsNullOrWhiteSpace(options.RemoteConfigurationFile)) builder.Services.AddHostedService(); builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.AddMcpServer().WithHttpTransport(o => o.Stateless = true).WithTools(); configure?.Invoke(builder); builder.Services.AddRouting(); var app = builder.Build(); app.Use(async (context, next) => { var correlationId = Guid.NewGuid().ToString("N"); var logger = context.RequestServices.GetRequiredService().CreateLogger("WxAgent.Http"); var stopwatch = System.Diagnostics.Stopwatch.StartNew(); context.Response.Headers["X-Correlation-Id"] = correlationId; context.Response.Headers.CacheControl = "no-store"; context.Response.Headers["X-Content-Type-Options"] = "nosniff"; context.Response.Headers["Referrer-Policy"] = "no-referrer"; context.Response.Headers.ContentSecurityPolicy = "default-src 'self'; script-src 'self'; style-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"; try { var security = context.RequestServices.GetRequiredService(); security.ValidateSource(context); // There are no tokens in query strings, including MCP initialization URLs. if (context.Request.Query.Keys.Any(k => k.Contains("token", StringComparison.OrdinalIgnoreCase))) throw new ServiceException("InvalidRequest", 400, "Credentials must not be supplied in URLs."); var path = context.Request.Path.Value ?? "/"; var isStatic = !path.StartsWith("/api/", StringComparison.Ordinal) && path != "/mcp"; if (!(path == "/api/v1/login" && HttpMethods.IsPost(context.Request.Method)) && !isStatic) context.Items[typeof(ServiceIdentity)] = security.AuthenticateRequest(context); await next(context); } catch (Exception e) when (!context.Response.HasStarted) { var (code, status, message) = e switch { ServiceException se => (se.Code, se.StatusCode, se.Message), BadHttpRequestException or JsonException => ("InvalidRequest", 400, "Invalid request."), OperationCanceledException => ("Cancelled", 408, "Request cancelled or timed out."), _ => ("InternalError", 500, "Request failed; use the correlation ID for diagnosis.") }; logger.LogError(e, "HTTP {Method} {Path} failed with {StatusCode} {ErrorCode}; correlationId={CorrelationId}", context.Request.Method, context.Request.Path, status, code, correlationId); context.Response.StatusCode = status; await context.Response.WriteAsJsonAsync(new { correlationId, error = new { code, message, stage = "request", retry = false } }); } finally { logger.LogInformation("HTTP {Method} {Path} -> {StatusCode}; correlationId={CorrelationId}; elapsedMs={ElapsedMs}", context.Request.Method, context.Request.Path, context.Response.StatusCode, correlationId, stopwatch.ElapsedMilliseconds); } }); app.UseDefaultFiles(); app.UseStaticFiles(); app.MapPost("/api/v1/login", (HttpContext context, LoginRequest request, ServiceSecurity security) => security.Login(context, request.Token ?? "")); app.MapPost("/api/v1/logout", (HttpContext context, ServiceSecurity security) => { security.Logout(context); return Results.NoContent(); }); app.MapGet("/api/v1/status", (AgentService service, CancellationToken ct) => service.StatusAsync(ct)); app.MapGet("/api/v1/diagnostics", (AgentService service, CancellationToken ct) => service.DiagnoseAsync(ct)); app.MapGet("/api/v1/capabilities", (AgentService service) => service.Capabilities()); app.MapGet("/api/v1/accounts", (int? limit, int? offset, AgentService service, CancellationToken ct) => service.AccountsAsync(limit ?? 50, offset ?? 0, ct)); app.MapGet("/api/v1/accounts/bindings", (AgentService service) => service.Bindings()); app.MapPost("/api/v1/accounts/bind", (BindAccountRequest request, AgentService service, CancellationToken ct) => service.BindAccountAsync(request.AccountId, request.TargetId, ct)); app.MapPost("/api/v1/accounts/unbind", async (UnbindAccountRequest request, AgentService service, CancellationToken ct) => { await service.UnbindAccountAsync(request.AccountId, ct); return Results.NoContent(); }); app.MapGet("/api/v1/ui-targets", (AgentService service, CancellationToken ct) => service.UiTargetsAsync(ct)); app.MapGet("/api/v1/sessions", (string? accountId, int? limit, int? offset, AgentService service, CancellationToken ct) => service.SessionsAsync(accountId, limit ?? 50, offset ?? 0, ct)); app.MapGet("/api/v1/sessions/search", (string? accountId, string query, bool? exactOnly, int? limit, int? offset, AgentService service, CancellationToken ct) => service.SearchSessionsAsync(accountId, query, exactOnly ?? false, limit ?? 50, offset ?? 0, ct)); app.MapGet("/api/v1/sessions/current", (string? accountId, AgentService service, CancellationToken ct) => service.CurrentSessionAsync(accountId, ct)); app.MapPost("/api/v1/sessions/scroll", (ScrollRequest request, AgentService service, CancellationToken ct) => service.ScrollSessionsAsync(request.AccountId, request.Direction, request.Pages, ct)); app.MapPost("/api/v1/sessions/open", (OpenSessionRequest request, AgentService service, CancellationToken ct) => service.OpenSessionAsync(request.AccountId, request.AutomationId, ct)); app.MapGet("/api/v1/messages", (string? accountId, string? session, int? limit, int? offset, bool? includeContent, AgentService service, CancellationToken ct) => service.MessagesAsync(accountId, session, limit ?? 50, offset ?? 0, includeContent ?? false, ct)); app.MapGet("/api/v1/contacts", (string? accountId, string? contains, bool? groupsOnly, int? limit, int? offset, AgentService service, CancellationToken ct) => service.ContactsAsync(accountId, contains, groupsOnly, limit ?? 50, offset ?? 0, ct)); app.MapGet("/api/v1/groups/{accountId}/{group}/members", (string accountId, string group, int? limit, int? offset, AgentService service, CancellationToken ct) => service.GroupMembersAsync(accountId, group, limit ?? 50, offset ?? 0, ct)); app.MapGet("/api/v1/db/messages", (string accountId, string chatId, int? limit, int? offset, long? localId, AgentService service, CancellationToken ct) => service.DatabaseMessagesAsync(accountId, chatId, limit ?? 50, offset ?? 0, localId, ct)); app.MapGet("/api/v1/db/merged", (string accountId, string chatId, long localId, AgentService service, CancellationToken ct) => service.DatabaseMergedAsync(accountId, chatId, localId, ct)); app.MapGet("/api/v1/events", StreamEvents); app.MapPost("/api/v1/files", async (HttpContext context, AgentService service, CancellationToken ct) => { if (!context.Request.HasFormContentType) throw new ServiceException("InvalidRequest", 400, "multipart/form-data is required."); var form = await context.Request.ReadFormAsync(ct); if (form.Files.Count != 1) throw new ServiceException("InvalidRequest", 400, "Exactly one file is required."); return Results.Ok(await service.UploadAsync(form.Files[0], ct)); }); app.MapGet("/api/v1/files/{id}", (string id, AgentService service) => Results.File(service.Download(id), "application/octet-stream")); app.MapPost("/api/v1/operations", async (OperationSubmitRequest request, AgentService service, CancellationToken ct) => await service.SubmitOperationAsync(request, ct)); app.MapGet("/api/v1/operations", (string? accountId, int? limit, int? offset, AgentService service) => service.Operations(accountId, limit ?? 50, offset ?? 0)); app.MapGet("/api/v1/operations/{id}", (string id, AgentService service) => service.Operation(id)); app.MapPost("/api/v1/operations/{id}/cancel", (string id, AgentService service) => service.CancelOperation(id)); app.MapMcp("/mcp"); return app; } private static async Task StreamEvents(HttpContext context, EventHub hub, ServiceSecurity security, AgentService service) { var identity = context.Items[typeof(ServiceIdentity)] as ServiceIdentity ?? throw new ServiceException("Unauthorized", 401, "Authentication required."); service.RequireEvents(); var after = context.Request.Headers["Last-Event-ID"].ToString(); var (id, replay, gap) = hub.Subscribe(identity.PrincipalId, string.IsNullOrWhiteSpace(after) ? null : after); context.Response.ContentType = "text/event-stream"; context.Response.Headers.CacheControl = "no-store"; await context.Response.StartAsync(context.RequestAborted); await context.Response.Body.FlushAsync(context.RequestAborted); if (!hub.TryGet(id, identity.PrincipalId, out var subscription)) throw new ServiceException("SubscriptionUnavailable", 503, "Could not create event subscription."); try { if (gap) await WriteEvent(context, "gap", new { resyncRequired = true }); foreach (var item in replay) await WriteEvent(context, "message", item); if (replay.Count != 0) await context.Response.Body.FlushAsync(context.RequestAborted); while (!context.RequestAborted.IsCancellationRequested) { using var wake = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted); wake.CancelAfter(TimeSpan.FromSeconds(15)); bool available; try { available = await subscription.Channel.Reader.WaitToReadAsync(wake.Token); } catch (OperationCanceledException) when (!context.RequestAborted.IsCancellationRequested) { service.RequireEvents(); continue; } if (!available) break; security.RequireCurrent(identity, "read"); while (subscription.Channel.Reader.TryRead(out var item)) await WriteEvent(context, item.Kind == "gap" ? "gap" : "message", item); await context.Response.Body.FlushAsync(context.RequestAborted); } } catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) { } catch (ServiceException) when (context.Response.HasStarted) { } finally { hub.Remove(id); } } private static async Task WriteEvent(HttpContext context, string name, object value) { var id = value is AgentEvent e ? e.EventId : "control"; await context.Response.WriteAsync($"id: {id}\nevent: {name}\ndata: {JsonSerializer.Serialize(value, Json)}\n\n", context.RequestAborted); } public sealed record LoginRequest(string Token); public sealed record ScrollRequest(string? AccountId, string Direction, int Pages = 1); public sealed record OpenSessionRequest(string? AccountId, string AutomationId); public sealed record BindAccountRequest(string AccountId, string TargetId); public sealed record UnbindAccountRequest(string AccountId); }