First-time user experience fixes and platform contract repairs

FTUX fixes (Sprint 316-001):
- Remove all hardcoded fake data from dashboard — fresh installs show
  honest setup guide instead of fake crisis data (5 fake criticals gone)
- Curate advisory source defaults: 32 sources disabled by default
  (ecosystem, geo-restricted, exploit, hardware, mirror). ~43 core
  sources remain enabled. StellaOps Mirror no longer enabled at priority 1.
- Filter Mirror-category sources from Create Domain wizard to prevent
  circular mirror-from-mirror chains
- Add 404 catch-all route — unknown URLs show "Page Not Found" instead
  of silently rendering the dashboard
- Fix arrow characters in release target path dropdown (? → →)
- Add login credentials to quickstart documentation
- Update Feature Matrix: 14 release orchestration features marked as
  shipped (was marked planned)

Platform contract repairs (from prior session):
- Add /api/v1/jobengine/quotas/summary endpoint on Platform
- Fix gateway route prefix matching for /policy/shadow/* and
  /policy/simulations/* (regex routes instead of exact match)
- Fix VexHub PostgresVexSourceRepository missing interface method
- Fix advisory-vex-sources sweep text expectation
- Fix mirror operator journey auth (session storage token extraction)

Verified: 110/111 canonical routes passing (1 unrelated stale approval ref)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-16 02:05:38 +02:00
parent f4d3ef76db
commit 534aabfa2a
21 changed files with 3195 additions and 304 deletions

View File

@@ -0,0 +1,709 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy;
using System.Globalization;
namespace StellaOps.Platform.WebService.Endpoints;
public static class QuotaCompatibilityEndpoints
{
public static IEndpointRouteBuilder MapQuotaCompatibilityEndpoints(this IEndpointRouteBuilder app)
{
// --- Authority quota endpoints (routed from /api/v1/authority/quotas) ---
var quotaGroup = app.MapGroup("/api/v1/authority/quotas")
.WithTags("Quota Compatibility")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.OpsHealth))
.RequireTenant();
quotaGroup.MapGet("/dashboard", (TimeProvider timeProvider) =>
{
var now = timeProvider.GetUtcNow();
return Results.Ok(new
{
entitlement = BuildEntitlement(now),
consumption = BuildConsumptionMetrics(now),
tenantCount = 3,
activeAlerts = 1,
recentViolations = 4
});
})
.WithName("QuotaCompatibility.GetDashboard");
quotaGroup.MapGet("/", (TimeProvider timeProvider) =>
{
var now = timeProvider.GetUtcNow();
return Results.Ok(BuildEntitlement(now));
})
.WithName("QuotaCompatibility.GetEntitlement");
quotaGroup.MapGet("/consumption", (TimeProvider timeProvider) =>
{
var now = timeProvider.GetUtcNow();
return Results.Ok(BuildConsumptionMetrics(now));
})
.WithName("QuotaCompatibility.GetConsumption");
quotaGroup.MapGet("/history", (
[FromQuery] string? startDate,
[FromQuery] string? endDate,
[FromQuery] string? categories,
[FromQuery] string aggregation,
TimeProvider timeProvider) =>
{
var now = timeProvider.GetUtcNow();
var effectiveAggregation = string.IsNullOrWhiteSpace(aggregation) ? "daily" : aggregation;
var periodStart = ParseDateOrDefault(startDate, now.AddDays(-7));
var periodEnd = ParseDateOrDefault(endDate, now);
var requestedCategories = string.IsNullOrWhiteSpace(categories)
? new[] { "license", "jobs", "api", "storage", "scans" }
: categories.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return Results.Ok(new
{
period = new
{
start = periodStart.ToString("O", CultureInfo.InvariantCulture),
end = periodEnd.ToString("O", CultureInfo.InvariantCulture)
},
points = BuildHistoryPoints(periodStart, periodEnd, effectiveAggregation, requestedCategories),
aggregation = effectiveAggregation
});
})
.WithName("QuotaCompatibility.GetHistory");
quotaGroup.MapGet("/tenants", (
[FromQuery] string? search,
[FromQuery] string? sortBy,
[FromQuery] string sortDir,
[FromQuery] int limit,
[FromQuery] int offset,
TimeProvider timeProvider) =>
{
var now = timeProvider.GetUtcNow();
var effectiveLimit = limit > 0 ? limit : 50;
var all = BuildTenantQuotaUsages(now);
if (!string.IsNullOrWhiteSpace(search))
{
all = all.Where(t =>
t.TenantName.Contains(search, StringComparison.OrdinalIgnoreCase) ||
t.TenantId.Contains(search, StringComparison.OrdinalIgnoreCase)).ToArray();
}
var items = all.Skip(offset > 0 ? offset : 0).Take(effectiveLimit).ToArray();
return Results.Ok(new
{
items,
total = all.Length
});
})
.WithName("QuotaCompatibility.GetTenantQuotas");
quotaGroup.MapGet("/tenants/{tenantId}", (
string tenantId,
TimeProvider timeProvider) =>
{
var now = timeProvider.GetUtcNow();
return Results.Ok(BuildTenantBreakdown(tenantId, now));
})
.WithName("QuotaCompatibility.GetTenantBreakdown");
quotaGroup.MapGet("/forecast", (
[FromQuery] string? category,
[FromQuery] string? tenantId,
TimeProvider timeProvider) =>
{
return Results.Ok(BuildForecasts(category));
})
.WithName("QuotaCompatibility.GetForecast");
quotaGroup.MapGet("/alerts", () =>
{
return Results.Ok(BuildAlertConfig());
})
.WithName("QuotaCompatibility.GetAlertConfig");
quotaGroup.MapPost("/alerts", (QuotaAlertConfigRequest config) =>
{
// Accept and echo back the config (mock persistence)
return Results.Ok(config);
})
.WithName("QuotaCompatibility.SaveAlertConfig");
quotaGroup.MapPost("/reports", (
QuotaReportRequestBody request,
TimeProvider timeProvider) =>
{
var now = timeProvider.GetUtcNow();
return Results.Ok(new
{
reportId = $"quota-report-{now:yyyyMMddHHmmss}",
status = "completed",
downloadUrl = (string?)null,
createdAt = now.ToString("O", CultureInfo.InvariantCulture),
completedAt = now.AddSeconds(2).ToString("O", CultureInfo.InvariantCulture),
expiresAt = now.AddHours(24).ToString("O", CultureInfo.InvariantCulture)
});
})
.WithName("QuotaCompatibility.RequestReport");
quotaGroup.MapGet("/reports/{reportId}", (
string reportId,
TimeProvider timeProvider) =>
{
var now = timeProvider.GetUtcNow();
return Results.Ok(new
{
reportId,
status = "completed",
downloadUrl = (string?)null,
createdAt = now.AddMinutes(-5).ToString("O", CultureInfo.InvariantCulture),
completedAt = now.AddMinutes(-4).ToString("O", CultureInfo.InvariantCulture),
expiresAt = now.AddHours(24).ToString("O", CultureInfo.InvariantCulture)
});
})
.WithName("QuotaCompatibility.GetReportStatus");
// --- Gateway rate-limit endpoints (routed from /api/v1/gateway/rate-limits) ---
var rateLimitGroup = app.MapGroup("/api/v1/gateway/rate-limits")
.WithTags("Quota Compatibility")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.OpsHealth))
.RequireTenant();
rateLimitGroup.MapGet("/", (TimeProvider timeProvider) =>
{
var now = timeProvider.GetUtcNow();
return Results.Ok(BuildRateLimitStatuses(now));
})
.WithName("QuotaCompatibility.GetRateLimitStatus");
rateLimitGroup.MapGet("/violations", (
[FromQuery] string? startDate,
[FromQuery] string? endDate,
[FromQuery] string? tenantId,
[FromQuery] int limit,
TimeProvider timeProvider) =>
{
var now = timeProvider.GetUtcNow();
var effectiveLimit = limit > 0 ? limit : 50;
var periodStart = ParseDateOrDefault(startDate, now.AddDays(-7));
var periodEnd = ParseDateOrDefault(endDate, now);
return Results.Ok(new
{
items = BuildRateLimitViolations(now).Take(effectiveLimit).ToArray(),
total = 3,
period = new
{
start = periodStart.ToString("O", CultureInfo.InvariantCulture),
end = periodEnd.ToString("O", CultureInfo.InvariantCulture)
}
});
})
.WithName("QuotaCompatibility.GetRateLimitViolations");
// --- JobEngine quota endpoints (routed from /api/v1/jobengine/quotas) ---
var jobQuotaGroup = app.MapGroup("/api/v1/jobengine/quotas")
.WithTags("Quota Compatibility")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.OpsHealth))
.RequireTenant();
jobQuotaGroup.MapGet("/", () =>
{
return Results.Ok(new
{
concurrentJobs = new { current = 3, limit = 10 },
dailyJobLimit = new { current = 47, limit = 500 },
queueDepth = 2,
averageJobDuration = 34.5
});
})
.WithName("QuotaCompatibility.GetJobQuotaStatus");
jobQuotaGroup.MapGet("/summary", (TimeProvider timeProvider) =>
{
var now = timeProvider.GetUtcNow();
return Results.Ok(new
{
concurrentJobs = new { current = 3, limit = 10, percentage = 30.0 },
dailyJobLimit = new { current = 47, limit = 500, percentage = 9.4 },
queueDepth = 2,
averageJobDuration = 34.5,
activeWorkers = 4,
totalWorkers = 6,
lastUpdated = now.ToString("O", CultureInfo.InvariantCulture)
});
})
.WithName("QuotaCompatibility.GetJobQuotaSummary");
return app;
}
// ---- Data builders ----
private static object BuildEntitlement(DateTimeOffset now) => new
{
planId = "enterprise-v2",
planName = "Enterprise",
features = new[]
{
"unlimited_tenants", "advanced_scanning", "policy_engine",
"air_gap_mode", "federation", "custom_crypto"
},
limits = new
{
artifacts = 50000,
users = 500,
scansPerDay = 1000,
storageMb = 102400,
concurrentJobs = 10,
apiRequestsPerMinute = 600
},
validFrom = now.AddYears(-1).ToString("O", CultureInfo.InvariantCulture),
validTo = now.AddYears(1).ToString("O", CultureInfo.InvariantCulture),
renewalDate = now.AddMonths(10).ToString("O", CultureInfo.InvariantCulture)
};
private static object[] BuildConsumptionMetrics(DateTimeOffset now)
{
var ts = now.ToString("O", CultureInfo.InvariantCulture);
return
[
new
{
category = "license",
current = 142,
limit = 500,
percentage = 28.4,
status = "healthy",
trend = "up",
trendPercentage = 3.2,
lastUpdated = ts
},
new
{
category = "jobs",
current = 47,
limit = 500,
percentage = 9.4,
status = "healthy",
trend = "stable",
trendPercentage = 0.8,
lastUpdated = ts
},
new
{
category = "api",
current = 312,
limit = 600,
percentage = 52.0,
status = "healthy",
trend = "up",
trendPercentage = 5.1,
lastUpdated = ts
},
new
{
category = "storage",
current = 24576,
limit = 102400,
percentage = 24.0,
status = "healthy",
trend = "up",
trendPercentage = 1.8,
lastUpdated = ts
},
new
{
category = "scans",
current = 187,
limit = 1000,
percentage = 18.7,
status = "healthy",
trend = "stable",
trendPercentage = 0.4,
lastUpdated = ts
}
];
}
private static object[] BuildHistoryPoints(
DateTimeOffset start,
DateTimeOffset end,
string aggregation,
string[] categories)
{
var interval = aggregation switch
{
"hourly" => TimeSpan.FromHours(1),
"weekly" => TimeSpan.FromDays(7),
_ => TimeSpan.FromDays(1)
};
var points = new List<object>();
var cursor = start;
var step = 0;
while (cursor <= end && step < 200) // cap at 200 points
{
foreach (var cat in categories)
{
var (baseVal, limit) = cat switch
{
"license" => (130 + step * 2, 500),
"jobs" => (40 + (step % 10), 500),
"api" => (280 + step * 3, 600),
"storage" => (23000 + step * 100, 102400),
"scans" => (150 + step * 5, 1000),
_ => (50, 500)
};
var value = Math.Min(baseVal, limit);
points.Add(new
{
timestamp = cursor.ToString("O", CultureInfo.InvariantCulture),
category = cat,
value,
percentage = Math.Round(value * 100.0 / limit, 1)
});
}
cursor = cursor.Add(interval);
step++;
}
return points.ToArray();
}
private static TenantQuotaUsageDto[] BuildTenantQuotaUsages(DateTimeOffset now)
{
var ts = now.ToString("O", CultureInfo.InvariantCulture);
return
[
new TenantQuotaUsageDto(
"demo-prod",
"Demo Production",
"Enterprise",
new TenantQuotasDto(
new QuotaMetricDto("license", 82, 200, 41.0, "healthy", "up", 2.1, ts),
new QuotaMetricDto("jobs", 23, 200, 11.5, "healthy", "stable", 0.5, ts),
new QuotaMetricDto("api", 180, 300, 60.0, "healthy", "up", 4.2, ts),
new QuotaMetricDto("storage", 14000, 51200, 27.3, "healthy", "up", 1.5, ts)),
"up", 2.8, now.AddMinutes(-12).ToString("O", CultureInfo.InvariantCulture)),
new TenantQuotaUsageDto(
"staging-01",
"Staging Environment",
"Enterprise",
new TenantQuotasDto(
new QuotaMetricDto("license", 38, 200, 19.0, "healthy", "stable", 0.3, ts),
new QuotaMetricDto("jobs", 15, 200, 7.5, "healthy", "stable", 0.2, ts),
new QuotaMetricDto("api", 95, 300, 31.7, "healthy", "stable", 1.1, ts),
new QuotaMetricDto("storage", 7200, 51200, 14.1, "healthy", "stable", 0.4, ts)),
"stable", 0.6, now.AddMinutes(-35).ToString("O", CultureInfo.InvariantCulture)),
new TenantQuotaUsageDto(
"dev-sandbox",
"Developer Sandbox",
"Enterprise",
new TenantQuotasDto(
new QuotaMetricDto("license", 22, 100, 22.0, "healthy", "down", -1.2, ts),
new QuotaMetricDto("jobs", 9, 100, 9.0, "healthy", "stable", 0.1, ts),
new QuotaMetricDto("api", 37, 150, 24.7, "healthy", "down", -0.8, ts),
new QuotaMetricDto("storage", 3400, 25600, 13.3, "healthy", "stable", 0.2, ts)),
"down", -0.7, now.AddHours(-2).ToString("O", CultureInfo.InvariantCulture))
];
}
private static object BuildTenantBreakdown(string tenantId, DateTimeOffset now) => new
{
tenantId,
tenantName = tenantId switch
{
"demo-prod" => "Demo Production",
"staging-01" => "Staging Environment",
"dev-sandbox" => "Developer Sandbox",
_ => tenantId
},
planName = "Enterprise",
licensePeriod = new
{
start = now.AddYears(-1).ToString("O", CultureInfo.InvariantCulture),
end = now.AddYears(1).ToString("O", CultureInfo.InvariantCulture)
},
quotaDetails = new
{
artifacts = new { current = 1247, limit = 10000, percentage = 12.5 },
users = new { current = 28, limit = 200, percentage = 14.0 },
scansPerDay = new { current = 64, limit = 400, percentage = 16.0 },
storageMb = new { current = 14000, limit = 51200, percentage = 27.3 },
concurrentJobs = new { current = 3, limit = 10, percentage = 30.0 }
},
usageByResourceType = new object[]
{
new { type = "Container Images", percentage = 42.5 },
new { type = "SBOM Documents", percentage = 28.3 },
new { type = "VEX Advisories", percentage = 15.7 },
new { type = "Policy Artifacts", percentage = 8.2 },
new { type = "Evidence Records", percentage = 5.3 }
},
forecast = new
{
category = "storage",
exhaustionDays = 180,
confidence = 0.82,
trendSlope = 1.8,
recommendation = "Storage usage is growing steadily. Consider archiving older evidence records.",
severity = "info"
}
};
private static object[] BuildForecasts(string? category)
{
var all = new object[]
{
new
{
category = "license",
exhaustionDays = (int?)null,
confidence = 0.91,
trendSlope = 3.2,
recommendation = "License usage is well within limits. No action needed.",
severity = "info"
},
new
{
category = "jobs",
exhaustionDays = (int?)null,
confidence = 0.88,
trendSlope = 0.8,
recommendation = "Job usage is stable. Current capacity is adequate.",
severity = "info"
},
new
{
category = "api",
exhaustionDays = (int?)120,
confidence = 0.76,
trendSlope = 5.1,
recommendation = "API usage is trending upward. Monitor closely if trend continues.",
severity = "warning"
},
new
{
category = "storage",
exhaustionDays = (int?)180,
confidence = 0.82,
trendSlope = 1.8,
recommendation = "Storage usage is growing steadily. Consider archiving older evidence records.",
severity = "info"
},
new
{
category = "scans",
exhaustionDays = (int?)null,
confidence = 0.94,
trendSlope = 0.4,
recommendation = "Scan quota usage is low and stable.",
severity = "info"
}
};
// The anonymous objects don't expose "category" for filtering easily,
// so we return all if no filter, otherwise filter by matching category string in serialized form.
if (string.IsNullOrWhiteSpace(category))
{
return all;
}
// Return matching category only
return category switch
{
"license" => [all[0]],
"jobs" => [all[1]],
"api" => [all[2]],
"storage" => [all[3]],
"scans" => [all[4]],
_ => all
};
}
private static object BuildAlertConfig() => new
{
thresholds = new object[]
{
new { category = "license", enabled = true, warningThreshold = 75, criticalThreshold = 90 },
new { category = "jobs", enabled = true, warningThreshold = 80, criticalThreshold = 95 },
new { category = "api", enabled = true, warningThreshold = 70, criticalThreshold = 85 },
new { category = "storage", enabled = true, warningThreshold = 80, criticalThreshold = 95 },
new { category = "scans", enabled = false, warningThreshold = 80, criticalThreshold = 95 }
},
channels = new object[]
{
new
{
type = "email",
enabled = true,
target = "ops-team@stella-ops.local",
events = new[] { "warning", "critical", "recovery" }
},
new
{
type = "slack",
enabled = false,
target = "#ops-alerts",
events = new[] { "critical" }
}
},
quietHours = new { start = "22:00", end = "06:00" },
escalationMinutes = 30
};
private static object[] BuildRateLimitStatuses(DateTimeOffset now)
{
return
[
new
{
endpoint = "/api/v1/scanner/scan",
method = "POST",
limit = 60,
remaining = 42,
resetAt = now.AddMinutes(1).ToString("O", CultureInfo.InvariantCulture),
burstLimit = 10,
burstRemaining = 8
},
new
{
endpoint = "/api/v1/findings",
method = "GET",
limit = 120,
remaining = 98,
resetAt = now.AddMinutes(1).ToString("O", CultureInfo.InvariantCulture),
burstLimit = 20,
burstRemaining = 18
},
new
{
endpoint = "/api/v1/evidence",
method = "POST",
limit = 30,
remaining = 24,
resetAt = now.AddMinutes(1).ToString("O", CultureInfo.InvariantCulture),
burstLimit = 5,
burstRemaining = 4
},
new
{
endpoint = "/api/v1/policy/evaluate",
method = "POST",
limit = 90,
remaining = 71,
resetAt = now.AddMinutes(1).ToString("O", CultureInfo.InvariantCulture),
burstLimit = 15,
burstRemaining = 12
}
];
}
private static object[] BuildRateLimitViolations(DateTimeOffset now) =>
[
new
{
id = "rlv-001",
timestamp = now.AddMinutes(-22).ToString("O", CultureInfo.InvariantCulture),
tenantId = "demo-prod",
tenantName = "Demo Production",
endpoint = "/api/v1/scanner/scan",
method = "POST",
limitType = "burst",
currentRate = 14,
rateLimit = 10,
retryAfter = 8,
recommendation = "Reduce scan concurrency or spread requests over a longer window."
},
new
{
id = "rlv-002",
timestamp = now.AddHours(-3).ToString("O", CultureInfo.InvariantCulture),
tenantId = "staging-01",
tenantName = "Staging Environment",
endpoint = "/api/v1/findings",
method = "GET",
limitType = "sustained",
currentRate = 135,
rateLimit = 120,
retryAfter = 45,
recommendation = "Implement client-side caching for findings queries to reduce request volume."
},
new
{
id = "rlv-003",
timestamp = now.AddDays(-1).ToString("O", CultureInfo.InvariantCulture),
tenantId = "demo-prod",
tenantName = "Demo Production",
endpoint = "/api/v1/evidence",
method = "POST",
limitType = "burst",
currentRate = 8,
rateLimit = 5,
retryAfter = 12,
recommendation = "Batch evidence submissions to reduce burst pressure."
}
];
private static DateTimeOffset ParseDateOrDefault(string? dateStr, DateTimeOffset fallback)
{
if (string.IsNullOrWhiteSpace(dateStr))
return fallback;
return DateTimeOffset.TryParse(dateStr, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsed)
? parsed
: fallback;
}
// ---- Request / response DTOs (private, scoped to this compatibility layer) ----
private sealed record QuotaAlertConfigRequest(
object[]? Thresholds,
object[]? Channels,
QuotaQuietHoursDto? QuietHours,
int EscalationMinutes);
private sealed record QuotaQuietHoursDto(string Start, string End);
private sealed record QuotaReportRequestBody(
string StartDate,
string EndDate,
string[]? TenantIds,
string[]? Categories,
string Format,
bool IncludeForecasts,
bool IncludeRecommendations);
private sealed record QuotaMetricDto(
string Category,
int Current,
int Limit,
double Percentage,
string Status,
string Trend,
double TrendPercentage,
string LastUpdated);
private sealed record TenantQuotasDto(
QuotaMetricDto License,
QuotaMetricDto Jobs,
QuotaMetricDto Api,
QuotaMetricDto Storage);
private sealed record TenantQuotaUsageDto(
string TenantId,
string TenantName,
string PlanName,
TenantQuotasDto Quotas,
string Trend,
double TrendPercentage,
string LastActivity);
}