V13-FE-006: consolidate approved UI and contract hardening
deploy / deploy (push) Successful in 1m52s
deploy / notify (push) Successful in 1s

This commit is contained in:
2026-08-13 02:41:00 +09:00
parent d79edae546
commit 3f293d8aa8
1278 changed files with 14384 additions and 1664 deletions
@@ -60,7 +60,6 @@ public sealed class TriggerIngestionEndpoint : Endpoint<IngestionRequest, Ingest
{
Post("/api/market/ingest");
Roles("DataAdmin");
AllowAnonymous();
}
public override async Task HandleAsync(IngestionRequest req, CancellationToken ct)
@@ -113,7 +112,7 @@ public sealed class GetIngestionStatusEndpoint : EndpointWithoutRequest<Ingestio
public override void Configure()
{
Get("/api/market/ingest/{jobId}");
AllowAnonymous();
Roles("DataAdmin");
}
public override async Task HandleAsync(CancellationToken ct)
@@ -71,7 +71,6 @@ public sealed class TriggerRebalanceEndpoint : Endpoint<RebalanceRequest, Rebala
{
Post("/api/portfolio/{portfolioId}/rebalance");
Roles("PortfolioManager");
AllowAnonymous();
}
public override async Task HandleAsync(RebalanceRequest req, CancellationToken ct)
@@ -120,7 +119,7 @@ public sealed class GetCompositionEndpoint : EndpointWithoutRequest<PortfolioCom
public override void Configure()
{
Get("/api/portfolio/{portfolioId}/composition");
AllowAnonymous();
Roles("PortfolioManager");
}
public override async Task HandleAsync(CancellationToken ct)
@@ -51,7 +51,7 @@ public sealed class GetRiskMetricsEndpoint : EndpointWithoutRequest<RiskMetricsR
public override void Configure()
{
Get("/api/portfolio/{portfolioId}/risk");
AllowAnonymous();
Roles("RiskAnalyst");
}
public override async Task HandleAsync(CancellationToken ct)
@@ -49,7 +49,6 @@ public sealed class TriggerStressTestEndpoint : Endpoint<TriggerStressTestReques
{
Post("/api/portfolio/{portfolioId}/stress");
Roles("RiskAnalyst");
AllowAnonymous();
}
public override async Task HandleAsync(TriggerStressTestRequest req, CancellationToken ct)
@@ -208,7 +207,7 @@ public sealed class GetAlertsEndpoint : EndpointWithoutRequest<GetAlertsResponse
public override void Configure()
{
Get("/api/portfolio/{portfolioId}/alerts");
AllowAnonymous();
Roles("RiskAnalyst");
}
public override async Task HandleAsync(CancellationToken ct)
@@ -86,7 +86,7 @@ public sealed class GetRiskDashboardEndpoint : EndpointWithoutRequest<DashboardR
public override void Configure()
{
Get("/api/dashboard/risk");
AllowAnonymous();
Roles("RiskAnalyst");
}
public override async Task HandleAsync(CancellationToken ct)
@@ -68,7 +68,8 @@ public class InitiateShadowRunEndpoint : Endpoint<InitiateShadowRunRequest, Init
/// POST /api/test/shadow-run-direct
/// Direct synchronous test execution (bypass Hangfire queue)
/// </summary>
public class TestDirectShadowRunEndpoint : Endpoint<InitiateShadowRunRequest, object>
[DontRegister]
public class TestDirectShadowRunEndpoint(IClock clock) : Endpoint<InitiateShadowRunRequest, object>
{
private ILogger<TestDirectShadowRunEndpoint>? _logger;
@@ -99,11 +100,11 @@ public class TestDirectShadowRunEndpoint : Endpoint<InitiateShadowRunRequest, ob
PhaseFilter: MarketPhaseFilter.All);
_logger.LogInformation("🚀 Executing ShadowRunJob directly (no queue)...");
var startTime = DateTime.UtcNow;
var startTime = clock.UtcNow;
await job.ExecuteAsync(command, ct);
var duration = DateTime.UtcNow - startTime;
var duration = clock.UtcNow - startTime;
_logger.LogInformation("✅ Direct execution completed in {Duration}ms", duration.TotalMilliseconds);
HttpContext.Response.StatusCode = 200;
@@ -0,0 +1,34 @@
using System.Diagnostics;
namespace KArtSell.Host.Infrastructure;
/// <summary>
/// Establishes one correlation identifier at the HTTP boundary.
/// Endpoint code may use TraceIdentifier or Items[CorrelationId], but both resolve
/// to the same value and the response exposes it for support/replay tracing.
/// </summary>
public sealed class CorrelationIdMiddleware(RequestDelegate next, ILogger<CorrelationIdMiddleware> logger)
{
public async Task InvokeAsync(HttpContext context)
{
var correlationId = ReadCorrelationId(context.Request.Headers["X-Correlation-Id"]);
context.TraceIdentifier = correlationId.ToString("D");
context.Items["CorrelationId"] = correlationId;
context.Response.Headers["X-Correlation-Id"] = correlationId.ToString("D");
Activity.Current?.SetTag("kartsell.correlation_id", correlationId.ToString("D"));
using (logger.BeginScope(new Dictionary<string, object?>
{
["CorrelationId"] = correlationId,
}))
{
await next(context);
}
}
private static Guid ReadCorrelationId(string? headerValue) =>
Guid.TryParse(headerValue, out var correlationId) && correlationId != Guid.Empty
? correlationId
: Guid.NewGuid();
}
@@ -0,0 +1,52 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace KArtSell.Host.OpenApi;
/// <summary>
/// Documents the error family produced by the Host's ProblemDetails,
/// authentication, authorization, routing, and conflict boundaries.
/// </summary>
public sealed class ProblemDetailsOperationFilter : IOperationFilter
{
private static readonly IReadOnlyDictionary<string, string> Responses =
new Dictionary<string, string>
{
["400"] = "Invalid request",
["401"] = "Authentication required",
["403"] = "Permission denied",
["404"] = "Resource not found",
["409"] = "Concurrency or business conflict",
["500"] = "Unexpected server error"
};
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
var schema = context.SchemaGenerator.GenerateSchema(
typeof(ProblemDetails), context.SchemaRepository);
operation.Responses ??= new OpenApiResponses();
operation.Description = string.Join(
Environment.NewLine,
operation.Description,
"ProblemDetails types: validation, business-rule, conflict, permission, not-found, integration, system.",
"Correlation: X-Correlation-Id.");
foreach (var (status, description) in Responses)
{
if (operation.Responses.ContainsKey(status))
{
continue;
}
operation.Responses[status] = new OpenApiResponse
{
Description = description,
Content = new Dictionary<string, OpenApiMediaType>
{
["application/problem+json"] = new() { Schema = schema }
}
};
}
}
}
+57 -2
View File
@@ -6,6 +6,7 @@ using Microsoft.Extensions.Caching.Memory;
using KArtSell.Host.Jobs;
using KArtSell.Host.Configuration;
using KArtSell.Host.Infrastructure;
using KArtSell.Host.OpenApi;
using KArtSell.Host.Observability;
using KArtSell.Host.Features.Observability;
using KArtSell.BuildingBlocks.Data;
@@ -16,6 +17,7 @@ using KArtSell.Modules.ModelOperations;
using KArtSell.Modules.ModelOperations.Scheduling;
using KArtSell.Modules.SignalEngine;
using Microsoft.AspNetCore.Authentication;
using Microsoft.OpenApi;
using Npgsql;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
@@ -24,6 +26,7 @@ using Serilog;
using Serilog.Events;
var builder = WebApplication.CreateBuilder(args);
var openApiGenerationRequested = TryGetOpenApiOutputPath(args, out _);
// Load Telegram secrets for Serilog notifications
var telegramBotToken = Environment.GetEnvironmentVariable("TELEGRAM_BOT") ?? string.Empty;
@@ -256,13 +259,21 @@ builder.Services.AddSignalEngineModule();
builder.Services.AddModelOperationsModule();
builder.Services.AddFastEndpoints(); // AFTER modules registered (so their endpoints are included)
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddSwaggerGen(options =>
{
// FastEndpoints commonly nests DTOs as Endpoint.Response. Type names alone
// collide across slices; a stable full-name id keeps the generated contract
// deterministic without changing the wire DTO shape.
options.CustomSchemaIds(type => type.FullName?.Replace('+', '.') ?? type.Name);
options.OperationFilter<ProblemDetailsOperationFilter>();
});
builder.Services.AddHangfire(config => config.UsePostgreSqlStorage(options =>
options.UseNpgsqlConnection(connectionString)));
// Hangfire server can be disabled via HANGFIRE_SERVER_ENABLED=false (useful for testing/debugging port binding)
var hangfireServerEnabled = Environment.GetEnvironmentVariable("HANGFIRE_SERVER_ENABLED") != "false";
var hangfireServerEnabled = !openApiGenerationRequested
&& Environment.GetEnvironmentVariable("HANGFIRE_SERVER_ENABLED") != "false";
if (hangfireServerEnabled)
{
builder.Services.AddHangfireServer(options =>
@@ -300,6 +311,7 @@ var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.UseSerilogRequestLogging();
app.UseMiddleware<CorrelationIdMiddleware>();
app.UseMiddleware<RateLimiterMiddleware>(); // Rate limiting middleware
if (app.Environment.IsDevelopment())
{
@@ -328,6 +340,32 @@ app.MapGet("/health/ready", async (NpgsqlDataSource source, CancellationToken ct
return Results.Ok(new { status = "ready", database = "reachable" });
});
if (TryGetOpenApiOutputPath(args, out var openApiOutputPath))
{
await app.StartAsync();
try
{
var swaggerProvider = app.Services.GetRequiredService<Swashbuckle.AspNetCore.Swagger.ISwaggerProvider>();
var document = swaggerProvider.GetSwagger("v1");
var outputDirectory = Path.GetDirectoryName(Path.GetFullPath(openApiOutputPath));
if (!string.IsNullOrWhiteSpace(outputDirectory))
{
Directory.CreateDirectory(outputDirectory);
}
await using var output = File.CreateText(openApiOutputPath);
var writer = new OpenApiJsonWriter(output);
document.SerializeAsV3(writer);
await output.FlushAsync();
}
finally
{
await app.StopAsync();
}
return;
}
// Start app in background and register Hangfire jobs after Kestrel binds
var logger = app.Services.GetRequiredService<ILogger<Program>>();
var runTask = app.RunAsync();
@@ -438,4 +476,21 @@ static string? ResolveSecret(string? configValue, string environmentVariable)
return null;
}
static bool TryGetOpenApiOutputPath(string[] arguments, out string outputPath)
{
const string switchName = "--output";
outputPath = string.Empty;
var generateRequested = arguments.Any(argument =>
string.Equals(argument, "--generate-openapi-spec-only", StringComparison.Ordinal));
var outputIndex = Array.FindIndex(arguments, argument =>
string.Equals(argument, switchName, StringComparison.Ordinal));
if (!generateRequested || outputIndex < 0 || outputIndex + 1 >= arguments.Length)
{
return false;
}
outputPath = arguments[outputIndex + 1];
return !string.IsNullOrWhiteSpace(outputPath) && !outputPath.StartsWith("--", StringComparison.Ordinal);
}
public partial class Program;
@@ -59,7 +59,7 @@ public class QueryAuditEventsEndpoint : Endpoint<QueryAuditEventsRequest, QueryA
public override void Configure()
{
Get("/audit/events");
AllowAnonymous(); // RBAC enforced at handler level (Compliance Officer role)
Roles("Compliance");
Summary(x =>
{
x.Summary = "Query Audit Events";
@@ -39,7 +39,7 @@ public class SubmitGdprRequestEndpoint : Endpoint<SubmitGdprRequestDto, GdprRequ
public override void Configure()
{
Post("/compliance/gdpr-request");
AllowAnonymous(); // RBAC enforced at handler level (Data Admin/Compliance Officer role)
Roles("DataAdmin", "Compliance");
Summary(x =>
{
x.Summary = "Submit GDPR Request";
@@ -22,7 +22,7 @@ public class CreateApprovalEndpoint : EndpointWithoutRequest<CreateApprovalRespo
public override void Configure()
{
Post("/approvals");
AllowAnonymous();
Roles("Maker");
}
public override async Task HandleAsync(CancellationToken ct)
@@ -51,7 +51,7 @@ public class GetApprovalsEndpoint : Endpoint<GetApprovalsRequest, GetApprovalsRe
public override void Configure()
{
Get("/approvals");
AllowAnonymous();
Roles("Maker", "Checker", "SRE");
}
public override async Task HandleAsync(GetApprovalsRequest req, CancellationToken ct)
@@ -76,7 +76,7 @@ public class ProposeForReviewEndpoint : EndpointWithoutRequest<ProposeForReviewR
public override void Configure()
{
Post("/approvals/{id}/propose");
AllowAnonymous();
Roles("Maker");
}
public override async Task HandleAsync(CancellationToken ct)
@@ -111,7 +111,7 @@ public class ActivateApprovalEndpoint : EndpointWithoutRequest<ActivateApprovalR
public override void Configure()
{
Post("/approvals/{id}/activate");
AllowAnonymous();
Roles("SRE");
}
public override async Task HandleAsync(CancellationToken ct)
@@ -140,7 +140,7 @@ public class GetApprovalByIdEndpoint : EndpointWithoutRequest<ApprovalDetailResp
public override void Configure()
{
Get("/approvals/{id}");
AllowAnonymous();
Roles("Maker", "Checker", "SRE");
}
public override async Task HandleAsync(CancellationToken ct)
@@ -183,7 +183,7 @@ public class ApproveApprovalEndpoint : Endpoint<ApproveApprovalRequest, ApproveA
public override void Configure()
{
Post("/approvals/{id}/approve");
AllowAnonymous();
Roles("Checker");
}
public override async Task HandleAsync(ApproveApprovalRequest req, CancellationToken ct)
@@ -6,10 +6,12 @@ using System.Linq;
using System.Threading.Tasks;
using FastEndpoints;
using KArtSell.BuildingBlocks.Time;
using Microsoft.AspNetCore.Http;
/// <summary>
/// GET /reconciliation/holdings - Returns current portfolio holdings
/// </summary>
[DontRegister]
public class GetHoldingsEndpoint : EndpointWithoutRequest<GetHoldingsResponse>
{
private readonly IReconciliationRepository _repository;
@@ -74,6 +76,7 @@ public class HoldingDto
/// <summary>
/// GET /reconciliation/mismatches - Returns flagged discrepancies
/// </summary>
[DontRegister]
public class GetMismatchesEndpoint : EndpointWithoutRequest<GetMismatchesResponse>
{
private readonly IReconciliationRepository _repository;
@@ -148,6 +151,7 @@ public class MismatchDto
/// <summary>
/// POST /reconciliation/reconcile-trade - Trigger trade reconciliation
/// </summary>
[DontRegister]
public class ReconcileTradeEndpoint : Endpoint<ReconcileTradeRequest>
{
private readonly ReconcileTradeHandler _handler;
@@ -165,6 +169,17 @@ public class ReconcileTradeEndpoint : Endpoint<ReconcileTradeRequest>
public override async Task HandleAsync(ReconcileTradeRequest request, CancellationToken ct)
{
if (!ReconcileTradeRequestContract.HasIdempotencyKey(request))
{
await Send.ResponseAsync(new
{
type = "https://httpstatuses.com/400",
title = "Invalid reconciliation request",
detail = "Idempotency-Key is required for replay-safe reconciliation."
}, StatusCodes.Status400BadRequest, ct);
return;
}
var command = new ReconcileTradeCommand
{
TradeId = request.TradeId,
@@ -186,6 +201,12 @@ public class ReconcileTradeEndpoint : Endpoint<ReconcileTradeRequest>
}
}
public static class ReconcileTradeRequestContract
{
public static bool HasIdempotencyKey(ReconcileTradeRequest request) =>
request is not null && !string.IsNullOrWhiteSpace(request.IdempotencyKey);
}
public class ReconcileTradeRequest
{
public Guid TradeId { get; set; }
@@ -204,6 +225,7 @@ public class ReconcileTradeRequest
/// <summary>
/// GET /reconciliation/report/daily - Returns daily reconciliation report
/// </summary>
[DontRegister]
public class GetDailyReportEndpoint : EndpointWithoutRequest<ReconciliationReportDto>
{
private readonly ReconciliationEngine _engine;
@@ -51,6 +51,10 @@ public class ReconcileTradeHandler
public async Task HandleAsync(ReconcileTradeCommand command)
{
ArgumentNullException.ThrowIfNull(command);
if (string.IsNullOrWhiteSpace(command.IdempotencyKey))
{
throw new ArgumentException("IdempotencyKey is required for replay-safe reconciliation.", nameof(command));
}
// DEBT-018: share one connection/transaction across the engine's holding/log writes and
// the outbox event(s) below, instead of the engine writing on its own connection and the
@@ -93,7 +97,7 @@ public class ReconcileTradeHandler
: null,
ReconciliationTimestamp = _clock.UtcNow.UtcDateTime,
CorrelationId = command.CorrelationId,
IdempotencyKey = command.IdempotencyKey ?? Guid.NewGuid().ToString()
IdempotencyKey = command.IdempotencyKey
};
await PublishAsync(transaction, "TradeReconciled", @event, command.CorrelationId, CancellationToken.None);
@@ -18,7 +18,6 @@ public class CreateSellDecisionEndpoint : Endpoint<CreateSellDecisionRequest, Cr
{
Post("/sell-decisions");
Roles("Maker");
AllowAnonymous();
}
public override async Task HandleAsync(CreateSellDecisionRequest req, CancellationToken ct)
@@ -45,7 +44,6 @@ public class ListSellDecisionsEndpoint : EndpointWithoutRequest<ListSellDecision
{
Get("/sell-decisions");
Roles("Quant", "Maker", "Checker");
AllowAnonymous();
}
public override async Task HandleAsync(CancellationToken ct)
@@ -112,7 +110,6 @@ public class ExecuteSellDecisionEndpoint : Endpoint<ExecuteSellDecisionRequest,
{
Post("/sell-decisions/{id}/execute");
Roles("Maker", "Checker");
AllowAnonymous();
}
public override async Task HandleAsync(ExecuteSellDecisionRequest req, CancellationToken ct)
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging;
namespace KArtSell.Modules.ModelOperations.TradeExecution;
[DontRegister]
public class CreateTradeEndpoint : Endpoint<CreateTradeRequest, CreateTradeResponse>
{
private readonly SubmitTradeHandler _handler;
@@ -53,6 +54,7 @@ public class CreateTradeEndpoint : Endpoint<CreateTradeRequest, CreateTradeRespo
}
}
[DontRegister]
public class ListTradesEndpoint : Endpoint<EmptyRequest, ListTradesResponse>
{
private readonly ITradeSql _sql;