fix tests. new product advisories enhancements
This commit is contained in:
@@ -243,7 +243,7 @@ public static class DeltasEndpoints
|
||||
deltas.MapGet("/{deltaId}/attestation", async Task<IResult>(
|
||||
string deltaId,
|
||||
IMemoryCache cache,
|
||||
IDeltaVerdictAttestor? attestor,
|
||||
[FromServices] IDeltaVerdictAttestor? attestor,
|
||||
ILogger<DeltaComputer> logger,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
|
||||
@@ -166,6 +166,12 @@ public static class GatesEndpoints
|
||||
HttpContext httpContext,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Validate required fields
|
||||
if (string.IsNullOrWhiteSpace(request.Justification))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Justification is required" });
|
||||
}
|
||||
|
||||
var decodedBomRef = Uri.UnescapeDataString(bomRef);
|
||||
var requestedBy = httpContext.User.Identity?.Name ?? "anonymous";
|
||||
|
||||
@@ -176,13 +182,22 @@ public static class GatesEndpoints
|
||||
requestedBy,
|
||||
ct);
|
||||
|
||||
var response = new GateExceptionResponse
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var exceptionId = result.ExceptionRef ?? $"EXC-{Guid.NewGuid():N}"[..20];
|
||||
|
||||
var response = new GateExceptionCreatedResponse
|
||||
{
|
||||
ExceptionId = exceptionId,
|
||||
Status = result.Granted ? "active" : "denied",
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
Granted = result.Granted,
|
||||
ExceptionRef = result.ExceptionRef,
|
||||
DenialReason = result.DenialReason,
|
||||
ExpiresAt = result.ExpiresAt,
|
||||
RequestedAt = DateTimeOffset.UtcNow
|
||||
ExpiresAt = result.ExpiresAt ?? now.AddDays(30),
|
||||
RequesterId = requestedBy,
|
||||
Rationale = request.Justification ?? "",
|
||||
Scope = new GateExceptionScope { Type = "component", Target = decodedBomRef }
|
||||
};
|
||||
|
||||
return result.Granted
|
||||
@@ -698,7 +713,7 @@ public sealed record ExceptionRequest
|
||||
|
||||
/// <summary>Justification for bypass.</summary>
|
||||
[JsonPropertyName("justification")]
|
||||
public required string Justification { get; init; }
|
||||
public string? Justification { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -727,6 +742,103 @@ public sealed record GateExceptionResponse
|
||||
public DateTimeOffset RequestedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for gate exception creation.
|
||||
/// Uses camelCase property names to match ExceptionResponse contract.
|
||||
/// </summary>
|
||||
public sealed record GateExceptionCreatedResponse
|
||||
{
|
||||
/// <summary>Exception identifier.</summary>
|
||||
[JsonPropertyName("exceptionId")]
|
||||
public required string ExceptionId { get; init; }
|
||||
|
||||
/// <summary>Current status of the exception.</summary>
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>When the exception was created.</summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>Whether exception was granted.</summary>
|
||||
[JsonPropertyName("granted")]
|
||||
public bool Granted { get; init; }
|
||||
|
||||
/// <summary>Exception reference.</summary>
|
||||
[JsonPropertyName("exceptionRef")]
|
||||
public string? ExceptionRef { get; init; }
|
||||
|
||||
/// <summary>Denial reason if not granted.</summary>
|
||||
[JsonPropertyName("denialReason")]
|
||||
public string? DenialReason { get; init; }
|
||||
|
||||
/// <summary>When exception expires.</summary>
|
||||
[JsonPropertyName("expiresAt")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>Version for optimistic concurrency.</summary>
|
||||
[JsonPropertyName("version")]
|
||||
public int Version { get; init; } = 1;
|
||||
|
||||
/// <summary>Exception type.</summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = "gate_bypass";
|
||||
|
||||
/// <summary>Owner ID.</summary>
|
||||
[JsonPropertyName("ownerId")]
|
||||
public string OwnerId { get; init; } = "system";
|
||||
|
||||
/// <summary>Requester ID.</summary>
|
||||
[JsonPropertyName("requesterId")]
|
||||
public string RequesterId { get; init; } = "anonymous";
|
||||
|
||||
/// <summary>Approver IDs.</summary>
|
||||
[JsonPropertyName("approverIds")]
|
||||
public IReadOnlyList<string> ApproverIds { get; init; } = [];
|
||||
|
||||
/// <summary>Last updated timestamp.</summary>
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>Reason code.</summary>
|
||||
[JsonPropertyName("reasonCode")]
|
||||
public string ReasonCode { get; init; } = "gate_bypass";
|
||||
|
||||
/// <summary>Rationale.</summary>
|
||||
[JsonPropertyName("rationale")]
|
||||
public string Rationale { get; init; } = "";
|
||||
|
||||
/// <summary>Evidence references.</summary>
|
||||
[JsonPropertyName("evidenceRefs")]
|
||||
public IReadOnlyList<string> EvidenceRefs { get; init; } = [];
|
||||
|
||||
/// <summary>Compensating controls.</summary>
|
||||
[JsonPropertyName("compensatingControls")]
|
||||
public IReadOnlyList<string> CompensatingControls { get; init; } = [];
|
||||
|
||||
/// <summary>Exception scope.</summary>
|
||||
[JsonPropertyName("scope")]
|
||||
public GateExceptionScope Scope { get; init; } = new();
|
||||
|
||||
/// <summary>Metadata.</summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scope for gate exception.
|
||||
/// </summary>
|
||||
public sealed record GateExceptionScope
|
||||
{
|
||||
/// <summary>Scope type.</summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = "component";
|
||||
|
||||
/// <summary>Scope target.</summary>
|
||||
[JsonPropertyName("target")]
|
||||
public string Target { get; init; } = "";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Gate Decision History DTOs
|
||||
|
||||
@@ -104,12 +104,23 @@ public static class GovernanceEndpoints
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
[FromQuery] string? tenantId)
|
||||
{
|
||||
var tenant = tenantId ?? GetTenantId(httpContext) ?? "default";
|
||||
var tenant = tenantId ?? GetTenantId(httpContext);
|
||||
if (string.IsNullOrEmpty(tenant))
|
||||
{
|
||||
return Task.FromResult(Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Tenant ID required",
|
||||
Status = 400,
|
||||
Detail = "Provide tenant via X-StellaOps-Tenant header or tenantId query parameter"
|
||||
}));
|
||||
}
|
||||
|
||||
var state = SealedModeStates.GetOrAdd(tenant, _ => new SealedModeState());
|
||||
|
||||
var response = new SealedModeStatusResponse
|
||||
{
|
||||
IsSealed = state.IsSealed,
|
||||
TenantId = tenant,
|
||||
SealedAt = state.SealedAt,
|
||||
SealedBy = state.SealedBy,
|
||||
Reason = state.Reason,
|
||||
@@ -137,7 +148,7 @@ public static class GovernanceEndpoints
|
||||
.Select(MapOverrideToResponse)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult(Results.Ok(overrides));
|
||||
return Task.FromResult(Results.Ok(new { overrides }));
|
||||
}
|
||||
|
||||
private static Task<IResult> ToggleSealedModeAsync(
|
||||
@@ -181,6 +192,7 @@ public static class GovernanceEndpoints
|
||||
var response = new SealedModeStatusResponse
|
||||
{
|
||||
IsSealed = state.IsSealed,
|
||||
TenantId = tenant,
|
||||
SealedAt = state.SealedAt,
|
||||
SealedBy = state.SealedBy,
|
||||
Reason = state.Reason,
|
||||
@@ -200,20 +212,25 @@ public static class GovernanceEndpoints
|
||||
SealedModeOverrideRequest request)
|
||||
{
|
||||
var tenant = GetTenantId(httpContext) ?? "default";
|
||||
var actor = GetActorId(httpContext) ?? "system";
|
||||
var actor = request.Actor ?? GetActorId(httpContext) ?? "system";
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var overrideId = $"override-{Guid.NewGuid():N}";
|
||||
var overrideType = request.OverrideType ?? request.Type ?? "general";
|
||||
var durationHours = request.DurationMinutes > 0
|
||||
? (double)request.DurationMinutes / 60.0
|
||||
: request.DurationHours > 0 ? request.DurationHours : 24;
|
||||
|
||||
var entity = new SealedModeOverrideEntity
|
||||
{
|
||||
Id = overrideId,
|
||||
TenantId = tenant,
|
||||
Type = request.Type,
|
||||
Target = request.Target,
|
||||
Reason = request.Reason,
|
||||
Type = overrideType,
|
||||
Target = request.Target ?? "system",
|
||||
Reason = request.Reason ?? "",
|
||||
ApprovalId = $"approval-{Guid.NewGuid():N}",
|
||||
ApprovedBy = [actor],
|
||||
ExpiresAt = now.AddHours(request.DurationHours).ToString("O", CultureInfo.InvariantCulture),
|
||||
ExpiresAt = now.AddHours(durationHours).ToString("O", CultureInfo.InvariantCulture),
|
||||
CreatedAt = now.ToString("O", CultureInfo.InvariantCulture),
|
||||
Active = true
|
||||
};
|
||||
@@ -221,9 +238,19 @@ public static class GovernanceEndpoints
|
||||
Overrides[overrideId] = entity;
|
||||
|
||||
RecordAudit(tenant, actor, "sealed_mode_override_created", overrideId, "sealed_mode_override",
|
||||
$"Created override for {request.Target}: {request.Reason}", timeProvider);
|
||||
$"Created override for {entity.Target}: {entity.Reason}", timeProvider);
|
||||
|
||||
return Task.FromResult(Results.Ok(MapOverrideToResponse(entity)));
|
||||
return Task.FromResult(Results.Created(
|
||||
$"/api/v1/governance/sealed-mode/overrides/{overrideId}",
|
||||
new SealedModeOverrideCreatedResponse
|
||||
{
|
||||
OverrideId = overrideId,
|
||||
OverrideType = overrideType,
|
||||
Reason = entity.Reason,
|
||||
ExpiresAt = entity.ExpiresAt,
|
||||
CreatedAt = entity.CreatedAt,
|
||||
Active = entity.Active
|
||||
}));
|
||||
}
|
||||
|
||||
private static Task<IResult> RevokeSealedModeOverrideAsync(
|
||||
@@ -248,7 +275,7 @@ public static class GovernanceEndpoints
|
||||
Overrides[overrideId] = entity;
|
||||
|
||||
RecordAudit(tenant, actor, "sealed_mode_override_revoked", overrideId, "sealed_mode_override",
|
||||
$"Revoked override: {request.Reason}", timeProvider);
|
||||
$"Revoked override: {request.Reason ?? "no reason provided"}", timeProvider);
|
||||
|
||||
return Task.FromResult(Results.NoContent());
|
||||
}
|
||||
@@ -270,7 +297,7 @@ public static class GovernanceEndpoints
|
||||
.Select(MapProfileToResponse)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult(Results.Ok(profiles));
|
||||
return Task.FromResult(Results.Ok(new { profiles }));
|
||||
}
|
||||
|
||||
private static Task<IResult> GetRiskProfileAsync(
|
||||
@@ -301,7 +328,7 @@ public static class GovernanceEndpoints
|
||||
var actor = GetActorId(httpContext) ?? "system";
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var profileId = $"profile-{Guid.NewGuid():N}";
|
||||
var profileId = request.ProfileId ?? $"profile-{Guid.NewGuid():N}";
|
||||
var entity = new RiskProfileEntity
|
||||
{
|
||||
Id = profileId,
|
||||
@@ -446,13 +473,13 @@ public static class GovernanceEndpoints
|
||||
Status = "deprecated",
|
||||
ModifiedAt = now.ToString("O", CultureInfo.InvariantCulture),
|
||||
ModifiedBy = actor,
|
||||
DeprecationReason = request.Reason
|
||||
DeprecationReason = request.Reason ?? "deprecated"
|
||||
};
|
||||
|
||||
RiskProfiles[profileId] = entity;
|
||||
|
||||
RecordAudit(tenant, actor, "risk_profile_deprecated", profileId, "risk_profile",
|
||||
$"Deprecated risk profile: {entity.Name} - {request.Reason}", timeProvider);
|
||||
$"Deprecated risk profile: {entity.Name} - {request.Reason ?? "no reason"}", timeProvider);
|
||||
|
||||
return Task.FromResult(Results.Ok(MapProfileToResponse(entity)));
|
||||
}
|
||||
@@ -484,7 +511,7 @@ public static class GovernanceEndpoints
|
||||
|
||||
var response = new RiskProfileValidationResponse
|
||||
{
|
||||
Valid = errors.Count == 0,
|
||||
IsValid = errors.Count == 0,
|
||||
Errors = errors,
|
||||
Warnings = warnings
|
||||
};
|
||||
@@ -502,7 +529,16 @@ public static class GovernanceEndpoints
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
var tenant = tenantId ?? GetTenantId(httpContext) ?? "default";
|
||||
var tenant = tenantId ?? GetTenantId(httpContext);
|
||||
if (string.IsNullOrEmpty(tenant))
|
||||
{
|
||||
return Task.FromResult(Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Tenant ID required",
|
||||
Status = 400,
|
||||
Detail = "Provide tenant via X-StellaOps-Tenant header or tenantId query parameter"
|
||||
}));
|
||||
}
|
||||
|
||||
var events = AuditEntries.Values
|
||||
.Where(e => e.TenantId == tenant)
|
||||
@@ -629,7 +665,7 @@ public static class GovernanceEndpoints
|
||||
{
|
||||
return new RiskProfileResponse
|
||||
{
|
||||
Id = entity.Id,
|
||||
ProfileId = entity.Id,
|
||||
Version = entity.Version,
|
||||
Name = entity.Name,
|
||||
Description = entity.Description,
|
||||
@@ -737,20 +773,34 @@ public sealed record SealedModeToggleRequest
|
||||
|
||||
public sealed record SealedModeOverrideRequest
|
||||
{
|
||||
public required string Type { get; init; }
|
||||
public required string Target { get; init; }
|
||||
public required string Reason { get; init; }
|
||||
public int DurationHours { get; init; } = 24;
|
||||
public string? Type { get; init; }
|
||||
public string? OverrideType { get; init; }
|
||||
public string? Target { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
public int DurationHours { get; init; }
|
||||
public int DurationMinutes { get; init; }
|
||||
public string? Actor { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SealedModeOverrideCreatedResponse
|
||||
{
|
||||
public required string OverrideId { get; init; }
|
||||
public required string OverrideType { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
public required string ExpiresAt { get; init; }
|
||||
public required string CreatedAt { get; init; }
|
||||
public required bool Active { get; init; }
|
||||
}
|
||||
|
||||
public sealed record RevokeOverrideRequest
|
||||
{
|
||||
public required string Reason { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SealedModeStatusResponse
|
||||
{
|
||||
public required bool IsSealed { get; init; }
|
||||
public string? TenantId { get; init; }
|
||||
public string? SealedAt { get; init; }
|
||||
public string? SealedBy { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
@@ -776,6 +826,7 @@ public sealed record SealedModeOverrideResponse
|
||||
|
||||
public sealed record CreateRiskProfileRequest
|
||||
{
|
||||
public string? ProfileId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string? ExtendsProfile { get; init; }
|
||||
@@ -795,7 +846,7 @@ public sealed record UpdateRiskProfileRequest
|
||||
|
||||
public sealed record DeprecateProfileRequest
|
||||
{
|
||||
public required string Reason { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ValidateRiskProfileRequest
|
||||
@@ -806,7 +857,7 @@ public sealed record ValidateRiskProfileRequest
|
||||
|
||||
public sealed record RiskProfileResponse
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string ProfileId { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public string? Description { get; init; }
|
||||
@@ -823,7 +874,7 @@ public sealed record RiskProfileResponse
|
||||
|
||||
public sealed record RiskProfileValidationResponse
|
||||
{
|
||||
public required bool Valid { get; init; }
|
||||
public required bool IsValid { get; init; }
|
||||
public required List<ValidationError> Errors { get; init; }
|
||||
public required List<ValidationWarning> Warnings { get; init; }
|
||||
}
|
||||
|
||||
@@ -30,13 +30,13 @@ public static class ScoreGateEndpoints
|
||||
// POST /api/v1/gate/evaluate - Evaluate score-based gate for a finding
|
||||
gates.MapPost("/evaluate", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
ScoreGateEvaluateRequest request,
|
||||
ScoreGateEvaluateRequest? request,
|
||||
IEvidenceWeightedScoreCalculator ewsCalculator,
|
||||
IVerdictBundleBuilder verdictBuilder,
|
||||
IVerdictSigningService signingService,
|
||||
IVerdictRekorAnchorService anchorService,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
ILogger logger,
|
||||
[FromServices] ILogger<ScoreGateLog> logger,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (request is null)
|
||||
@@ -166,7 +166,7 @@ public static class ScoreGateEndpoints
|
||||
IVerdictSigningService signingService,
|
||||
IVerdictRekorAnchorService anchorService,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
ILogger logger,
|
||||
[FromServices] ILogger<ScoreGateLog> logger,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (request is null || request.Findings is null || request.Findings.Count == 0)
|
||||
@@ -285,7 +285,16 @@ public static class ScoreGateEndpoints
|
||||
|
||||
var tasks = findings.Select(async finding =>
|
||||
{
|
||||
await semaphore.WaitAsync(linkedCts.Token);
|
||||
try
|
||||
{
|
||||
await semaphore.WaitAsync(linkedCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Fail-fast triggered; skip remaining findings
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (linkedCts.Token.IsCancellationRequested)
|
||||
@@ -308,11 +317,16 @@ public static class ScoreGateEndpoints
|
||||
// Check fail-fast
|
||||
if (options.FailFast && decision.Action == ScoreGateActions.Block)
|
||||
{
|
||||
failFastToken.Cancel();
|
||||
await failFastToken.CancelAsync();
|
||||
}
|
||||
|
||||
return decision;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Fail-fast triggered during evaluation; skip this finding
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
semaphore.Release();
|
||||
@@ -437,7 +451,8 @@ public static class ScoreGateEndpoints
|
||||
"function" or "function_level" => 0.7,
|
||||
"package" or "package_level" => 0.3,
|
||||
"none" => 0.0,
|
||||
_ => 0.0
|
||||
// Unknown/unspecified: use conservative default assuming partial reachability
|
||||
_ => 0.5
|
||||
};
|
||||
}
|
||||
|
||||
@@ -531,3 +546,5 @@ public static class ScoreGateEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ScoreGateLog;
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ using StellaOps.Policy.Persistence.Postgres;
|
||||
using Polly;
|
||||
using Polly.Extensions.Http;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.Determinism;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -122,6 +123,7 @@ builder.Services.AddOptions<ToolLatticeOptions>()
|
||||
|
||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<PolicyGatewayOptions>>().Value);
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSystemGuidProvider();
|
||||
builder.Services.AddRouting(options => options.LowercaseUrls = true);
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddHealthChecks();
|
||||
@@ -156,6 +158,10 @@ builder.Services.AddSingleton<InMemoryGateEvaluationQueue>();
|
||||
builder.Services.AddSingleton<IGateEvaluationQueue>(sp => sp.GetRequiredService<InMemoryGateEvaluationQueue>());
|
||||
builder.Services.AddHostedService<GateEvaluationWorker>();
|
||||
|
||||
// Unknowns gate services (Sprint: SPRINT_20260118_018_Unknowns_queue_enhancement)
|
||||
builder.Services.Configure<StellaOps.Policy.Gates.UnknownsGateOptions>(_ => { });
|
||||
builder.Services.AddHttpClient<StellaOps.Policy.Gates.IUnknownsGateChecker, StellaOps.Policy.Gates.UnknownsGateChecker>();
|
||||
|
||||
// Gate bypass audit services (Sprint: SPRINT_20251226_001_BE_cicd_gate_integration, Task: CICD-GATE-06)
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Audit.IGateBypassAuditRepository,
|
||||
StellaOps.Policy.Audit.InMemoryGateBypassAuditRepository>();
|
||||
@@ -560,6 +566,9 @@ app.MapDeltasEndpoints();
|
||||
// Gate evaluation endpoints (Sprint: SPRINT_20251226_001_BE_cicd_gate_integration)
|
||||
app.MapGateEndpoints();
|
||||
|
||||
// Unknowns gate endpoints (Sprint: SPRINT_20260118_030_LIB_verdict_rekor_gate_api)
|
||||
app.MapGatesEndpoints();
|
||||
|
||||
// Score-based gate evaluation endpoints (Sprint: SPRINT_20260118_030_LIB_verdict_rekor_gate_api)
|
||||
app.MapScoreGateEndpoints();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user