feat: Implement air-gap functionality with timeline impact and evidence snapshot services
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
- Added AirgapTimelineImpact, AirgapTimelineImpactInput, and AirgapTimelineImpactResult records for managing air-gap bundle import impacts. - Introduced EvidenceSnapshotRecord, EvidenceSnapshotLinkInput, and EvidenceSnapshotLinkResult records for linking findings to evidence snapshots. - Created IEvidenceSnapshotRepository interface for managing evidence snapshot records. - Developed StalenessValidationService to validate staleness and enforce freshness thresholds. - Implemented AirgapTimelineService for emitting timeline events related to bundle imports. - Added EvidenceSnapshotService for linking findings to evidence snapshots and verifying their validity. - Introduced AirGapOptions for configuring air-gap staleness enforcement and thresholds. - Added minimal jsPDF stub for offline/testing builds in the web application. - Created TypeScript definitions for jsPDF to enhance type safety in the web application.
This commit is contained in:
@@ -51,6 +51,7 @@ using StellaOps.Aoc;
|
||||
using StellaOps.Aoc.AspNetCore.Routing;
|
||||
using StellaOps.Aoc.AspNetCore.Results;
|
||||
using StellaOps.Concelier.WebService.Contracts;
|
||||
using StellaOps.Concelier.WebService.Results;
|
||||
using StellaOps.Concelier.Core.Aoc;
|
||||
using StellaOps.Concelier.Core.Raw;
|
||||
using StellaOps.Concelier.RawModels;
|
||||
@@ -712,7 +713,7 @@ var observationsEndpoint = app.MapGet("/concelier/observations", async (
|
||||
{"reason", "format"},
|
||||
{"stage", "ingest"}
|
||||
});
|
||||
return Results.BadRequest(ex.Message);
|
||||
return ConcelierProblemResultFactory.ValidationFailed(context, ex.Message);
|
||||
}
|
||||
var elapsed = stopwatch.Elapsed;
|
||||
|
||||
@@ -867,7 +868,7 @@ app.MapGet("/v1/lnm/linksets/{advisoryId}", async (
|
||||
|
||||
if (string.IsNullOrWhiteSpace(advisoryId))
|
||||
{
|
||||
return Results.BadRequest("advisoryId is required.");
|
||||
return ConcelierProblemResultFactory.AdvisoryIdRequired(context);
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
@@ -880,7 +881,7 @@ app.MapGet("/v1/lnm/linksets/{advisoryId}", async (
|
||||
|
||||
if (result.Linksets.IsDefaultOrEmpty)
|
||||
{
|
||||
return Results.NotFound();
|
||||
return ConcelierProblemResultFactory.AdvisoryNotFound(context, advisoryId);
|
||||
}
|
||||
|
||||
var linkset = result.Linksets[0];
|
||||
@@ -1178,7 +1179,7 @@ var advisoryRawGetEndpoint = app.MapGet("/advisories/raw/{id}", async (
|
||||
var record = await rawService.FindByIdAsync(tenant, id.Trim(), cancellationToken).ConfigureAwait(false);
|
||||
if (record is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
return ConcelierProblemResultFactory.AdvisoryNotFound(context, id);
|
||||
}
|
||||
|
||||
var response = new AdvisoryRawRecordResponse(
|
||||
@@ -1222,7 +1223,7 @@ var advisoryRawProvenanceEndpoint = app.MapGet("/advisories/raw/{id}/provenance"
|
||||
var record = await rawService.FindByIdAsync(tenant, id.Trim(), cancellationToken).ConfigureAwait(false);
|
||||
if (record is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
return ConcelierProblemResultFactory.AdvisoryNotFound(context, id);
|
||||
}
|
||||
|
||||
var response = new AdvisoryRawProvenanceResponse(
|
||||
@@ -1241,6 +1242,379 @@ if (authorityConfigured)
|
||||
advisoryRawProvenanceEndpoint.RequireAuthorization(AdvisoryReadPolicyName);
|
||||
}
|
||||
|
||||
// Advisory observations endpoint - filtered by alias/purl/source with strict tenant scopes.
|
||||
// Echoes upstream values + provenance fields only (no merge-derived judgments).
|
||||
var advisoryObservationsEndpoint = app.MapGet("/advisories/observations", async (
|
||||
HttpContext context,
|
||||
[FromServices] IAdvisoryObservationQueryService observationService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
ApplyNoCache(context.Response);
|
||||
|
||||
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
var authorizationError = EnsureTenantAuthorized(context, tenant);
|
||||
if (authorizationError is not null)
|
||||
{
|
||||
return authorizationError;
|
||||
}
|
||||
|
||||
var query = context.Request.Query;
|
||||
|
||||
// Parse query parameters
|
||||
var aliases = query.TryGetValue("alias", out var aliasValues)
|
||||
? AdvisoryRawRequestMapper.NormalizeStrings(aliasValues)
|
||||
: null;
|
||||
|
||||
var purls = query.TryGetValue("purl", out var purlValues)
|
||||
? AdvisoryRawRequestMapper.NormalizeStrings(purlValues)
|
||||
: null;
|
||||
|
||||
var cpes = query.TryGetValue("cpe", out var cpeValues)
|
||||
? AdvisoryRawRequestMapper.NormalizeStrings(cpeValues)
|
||||
: null;
|
||||
|
||||
var observationIds = query.TryGetValue("id", out var idValues)
|
||||
? AdvisoryRawRequestMapper.NormalizeStrings(idValues)
|
||||
: null;
|
||||
|
||||
int? limit = null;
|
||||
if (query.TryGetValue("limit", out var limitValues) &&
|
||||
int.TryParse(limitValues.FirstOrDefault(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLimit) &&
|
||||
parsedLimit > 0)
|
||||
{
|
||||
limit = Math.Min(parsedLimit, 200); // Cap at 200
|
||||
}
|
||||
|
||||
string? cursor = null;
|
||||
if (query.TryGetValue("cursor", out var cursorValues))
|
||||
{
|
||||
var cursorValue = cursorValues.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(cursorValue))
|
||||
{
|
||||
cursor = cursorValue.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Build query options with tenant scope
|
||||
var options = new AdvisoryObservationQueryOptions(
|
||||
tenant,
|
||||
observationIds: observationIds,
|
||||
aliases: aliases,
|
||||
purls: purls,
|
||||
cpes: cpes,
|
||||
limit: limit,
|
||||
cursor: cursor);
|
||||
|
||||
var result = await observationService.QueryAsync(options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Map to response contracts
|
||||
var linksetResponse = new AdvisoryObservationLinksetAggregateResponse(
|
||||
result.Linkset.Aliases,
|
||||
result.Linkset.Purls,
|
||||
result.Linkset.Cpes,
|
||||
result.Linkset.References,
|
||||
result.Linkset.Scopes,
|
||||
result.Linkset.Relationships,
|
||||
result.Linkset.Confidence,
|
||||
result.Linkset.Conflicts);
|
||||
|
||||
var response = new AdvisoryObservationQueryResponse(
|
||||
result.Observations,
|
||||
linksetResponse,
|
||||
result.NextCursor,
|
||||
result.HasMore);
|
||||
|
||||
return JsonResult(response);
|
||||
}).WithName("GetAdvisoryObservations");
|
||||
|
||||
if (authorityConfigured)
|
||||
{
|
||||
advisoryObservationsEndpoint.RequireAuthorization(ObservationsPolicyName);
|
||||
}
|
||||
|
||||
// Advisory linksets endpoint - surfaces correlation + conflict payloads with ERR_AGG_* mapping.
|
||||
// No synthesis/merge - echoes upstream values only.
|
||||
var advisoryLinksetsEndpoint = app.MapGet("/advisories/linksets", async (
|
||||
HttpContext context,
|
||||
[FromServices] IAdvisoryLinksetQueryService linksetService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
ApplyNoCache(context.Response);
|
||||
|
||||
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
var authorizationError = EnsureTenantAuthorized(context, tenant);
|
||||
if (authorizationError is not null)
|
||||
{
|
||||
return authorizationError;
|
||||
}
|
||||
|
||||
var query = context.Request.Query;
|
||||
|
||||
// Parse advisory IDs (alias values like CVE-*, GHSA-*)
|
||||
var advisoryIds = query.TryGetValue("advisoryId", out var advisoryIdValues)
|
||||
? AdvisoryRawRequestMapper.NormalizeStrings(advisoryIdValues)
|
||||
: (query.TryGetValue("alias", out var aliasValues)
|
||||
? AdvisoryRawRequestMapper.NormalizeStrings(aliasValues)
|
||||
: null);
|
||||
|
||||
var sources = query.TryGetValue("source", out var sourceValues)
|
||||
? AdvisoryRawRequestMapper.NormalizeStrings(sourceValues)
|
||||
: null;
|
||||
|
||||
int? limit = null;
|
||||
if (query.TryGetValue("limit", out var limitValues) &&
|
||||
int.TryParse(limitValues.FirstOrDefault(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLimit) &&
|
||||
parsedLimit > 0)
|
||||
{
|
||||
limit = Math.Min(parsedLimit, 500); // Cap at 500
|
||||
}
|
||||
|
||||
string? cursor = null;
|
||||
if (query.TryGetValue("cursor", out var cursorValues))
|
||||
{
|
||||
var cursorValue = cursorValues.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(cursorValue))
|
||||
{
|
||||
cursor = cursorValue.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
var options = new AdvisoryLinksetQueryOptions(
|
||||
tenant,
|
||||
advisoryIds,
|
||||
sources,
|
||||
limit,
|
||||
cursor);
|
||||
|
||||
var result = await linksetService.QueryAsync(options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Map to LNM linkset response format
|
||||
var items = result.Linksets.Select(linkset => new LnmLinksetResponse(
|
||||
linkset.AdvisoryId,
|
||||
linkset.Source,
|
||||
linkset.Normalized?.Purls ?? Array.Empty<string>(),
|
||||
linkset.Normalized?.Cpes ?? Array.Empty<string>(),
|
||||
null, // Summary not available in linkset
|
||||
null, // PublishedAt
|
||||
null, // ModifiedAt
|
||||
null, // Severity - no derived judgment
|
||||
null, // Status
|
||||
linkset.Provenance is not null
|
||||
? new LnmLinksetProvenance(
|
||||
linkset.CreatedAt,
|
||||
null, // ConnectorId
|
||||
linkset.Provenance.ObservationHashes?.FirstOrDefault(),
|
||||
null) // DsseEnvelopeHash
|
||||
: null,
|
||||
linkset.Conflicts?.Select(c => new LnmLinksetConflict(
|
||||
c.Field,
|
||||
c.Reason,
|
||||
c.Values?.FirstOrDefault(),
|
||||
null,
|
||||
null)).ToArray() ?? Array.Empty<LnmLinksetConflict>(),
|
||||
Array.Empty<LnmLinksetTimeline>(),
|
||||
linkset.Normalized is not null
|
||||
? new LnmLinksetNormalized(
|
||||
null, // Aliases not in normalized
|
||||
linkset.Normalized.Purls,
|
||||
linkset.Normalized.Cpes,
|
||||
linkset.Normalized.Versions,
|
||||
null) // Ranges serialized differently
|
||||
: null,
|
||||
false, // Not from cache
|
||||
Array.Empty<string>(),
|
||||
linkset.ObservationIds.ToArray())).ToArray();
|
||||
|
||||
var response = new LnmLinksetPage(items, 1, items.Length, null);
|
||||
return JsonResult(response);
|
||||
}).WithName("GetAdvisoryLinksets");
|
||||
|
||||
if (authorityConfigured)
|
||||
{
|
||||
advisoryLinksetsEndpoint.RequireAuthorization(AdvisoryReadPolicyName);
|
||||
}
|
||||
|
||||
// Advisory linksets export endpoint for evidence bundles
|
||||
var advisoryLinksetsExportEndpoint = app.MapGet("/advisories/linksets/export", async (
|
||||
HttpContext context,
|
||||
[FromServices] IAdvisoryLinksetQueryService linksetService,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
ApplyNoCache(context.Response);
|
||||
|
||||
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
var authorizationError = EnsureTenantAuthorized(context, tenant);
|
||||
if (authorizationError is not null)
|
||||
{
|
||||
return authorizationError;
|
||||
}
|
||||
|
||||
var query = context.Request.Query;
|
||||
|
||||
var advisoryIds = query.TryGetValue("advisoryId", out var advisoryIdValues)
|
||||
? AdvisoryRawRequestMapper.NormalizeStrings(advisoryIdValues)
|
||||
: null;
|
||||
|
||||
var sources = query.TryGetValue("source", out var sourceValues)
|
||||
? AdvisoryRawRequestMapper.NormalizeStrings(sourceValues)
|
||||
: null;
|
||||
|
||||
var options = new AdvisoryLinksetQueryOptions(tenant, advisoryIds, sources, 1000, null);
|
||||
var result = await linksetService.QueryAsync(options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Export format with provenance metadata
|
||||
var exportItems = result.Linksets.Select(linkset => new
|
||||
{
|
||||
advisoryId = linkset.AdvisoryId,
|
||||
source = linkset.Source,
|
||||
tenantId = linkset.TenantId,
|
||||
observationIds = linkset.ObservationIds.ToArray(),
|
||||
confidence = linkset.Confidence,
|
||||
conflicts = linkset.Conflicts?.Select(c => new
|
||||
{
|
||||
field = c.Field,
|
||||
reason = c.Reason,
|
||||
values = c.Values,
|
||||
sourceIds = c.SourceIds
|
||||
}).ToArray(),
|
||||
normalized = linkset.Normalized is not null ? new
|
||||
{
|
||||
purls = linkset.Normalized.Purls,
|
||||
cpes = linkset.Normalized.Cpes,
|
||||
versions = linkset.Normalized.Versions
|
||||
} : null,
|
||||
provenance = linkset.Provenance is not null ? new
|
||||
{
|
||||
observationHashes = linkset.Provenance.ObservationHashes,
|
||||
toolVersion = linkset.Provenance.ToolVersion,
|
||||
policyHash = linkset.Provenance.PolicyHash
|
||||
} : null,
|
||||
createdAt = linkset.CreatedAt,
|
||||
builtByJobId = linkset.BuiltByJobId
|
||||
}).ToArray();
|
||||
|
||||
var export = new
|
||||
{
|
||||
tenant = tenant,
|
||||
exportedAt = timeProvider.GetUtcNow(),
|
||||
count = exportItems.Length,
|
||||
hasMore = result.HasMore,
|
||||
linksets = exportItems
|
||||
};
|
||||
|
||||
return JsonResult(export);
|
||||
}).WithName("ExportAdvisoryLinksets");
|
||||
|
||||
if (authorityConfigured)
|
||||
{
|
||||
advisoryLinksetsExportEndpoint.RequireAuthorization(AdvisoryReadPolicyName);
|
||||
}
|
||||
|
||||
// Internal endpoint for publishing observation events to NATS/Redis.
|
||||
// Publishes advisory.observation.updated@1 events with tenant + provenance references only.
|
||||
app.MapPost("/internal/events/observations/publish", async (
|
||||
HttpContext context,
|
||||
[FromBody] ObservationEventPublishRequest request,
|
||||
[FromServices] IAdvisoryObservationQueryService observationService,
|
||||
[FromServices] IAdvisoryObservationEventPublisher? eventPublisher,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
ApplyNoCache(context.Response);
|
||||
|
||||
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
if (eventPublisher is null)
|
||||
{
|
||||
return Problem(context, "Event publishing not configured", StatusCodes.Status503ServiceUnavailable, ProblemTypes.ServiceUnavailable, "Event publisher service is not available.");
|
||||
}
|
||||
|
||||
if (request?.ObservationIds is null || request.ObservationIds.Count == 0)
|
||||
{
|
||||
return Problem(context, "observationIds required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide at least one observation ID.");
|
||||
}
|
||||
|
||||
var options = new AdvisoryObservationQueryOptions(tenant, observationIds: request.ObservationIds);
|
||||
var result = await observationService.QueryAsync(options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var published = 0;
|
||||
foreach (var observation in result.Observations)
|
||||
{
|
||||
var @event = AdvisoryObservationUpdatedEvent.FromObservation(
|
||||
observation,
|
||||
supersedesId: null,
|
||||
traceId: context.TraceIdentifier);
|
||||
|
||||
await eventPublisher.PublishAsync(@event, cancellationToken).ConfigureAwait(false);
|
||||
published++;
|
||||
}
|
||||
|
||||
return Results.Ok(new { tenant, published, requestedCount = request.ObservationIds.Count, timestamp = timeProvider.GetUtcNow() });
|
||||
}).WithName("PublishObservationEvents");
|
||||
|
||||
// Internal endpoint for publishing linkset events to NATS/Redis.
|
||||
// Publishes advisory.linkset.updated@1 events with idempotent keys and tenant + provenance references.
|
||||
app.MapPost("/internal/events/linksets/publish", async (
|
||||
HttpContext context,
|
||||
[FromBody] LinksetEventPublishRequest request,
|
||||
[FromServices] IAdvisoryLinksetQueryService linksetService,
|
||||
[FromServices] IAdvisoryLinksetEventPublisher? eventPublisher,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
ApplyNoCache(context.Response);
|
||||
|
||||
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
if (eventPublisher is null)
|
||||
{
|
||||
return Problem(context, "Event publishing not configured", StatusCodes.Status503ServiceUnavailable, ProblemTypes.ServiceUnavailable, "Event publisher service is not available.");
|
||||
}
|
||||
|
||||
if (request?.AdvisoryIds is null || request.AdvisoryIds.Count == 0)
|
||||
{
|
||||
return Problem(context, "advisoryIds required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide at least one advisory ID.");
|
||||
}
|
||||
|
||||
var options = new AdvisoryLinksetQueryOptions(tenant, request.AdvisoryIds, null, 500);
|
||||
var result = await linksetService.QueryAsync(options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var published = 0;
|
||||
foreach (var linkset in result.Linksets)
|
||||
{
|
||||
var linksetId = $"{linkset.TenantId}:{linkset.Source}:{linkset.AdvisoryId}";
|
||||
var @event = AdvisoryLinksetUpdatedEvent.FromLinkset(
|
||||
linkset,
|
||||
previousLinkset: null,
|
||||
linksetId: linksetId,
|
||||
traceId: context.TraceIdentifier);
|
||||
|
||||
await eventPublisher.PublishAsync(@event, cancellationToken).ConfigureAwait(false);
|
||||
published++;
|
||||
}
|
||||
|
||||
return Results.Ok(new { tenant, published, requestedCount = request.AdvisoryIds.Count, hasMore = result.HasMore, timestamp = timeProvider.GetUtcNow() });
|
||||
}).WithName("PublishLinksetEvents");
|
||||
|
||||
var advisoryEvidenceEndpoint = app.MapGet("/vuln/evidence/advisories/{advisoryKey}", async (
|
||||
string advisoryKey,
|
||||
HttpContext context,
|
||||
@@ -1743,7 +2117,7 @@ var advisorySummaryEndpoint = app.MapGet("/advisories/summary", async (
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
return Results.BadRequest(ex.Message);
|
||||
return ConcelierProblemResultFactory.ValidationFailed(context, ex.Message);
|
||||
}
|
||||
|
||||
var items = queryResult.Linksets
|
||||
@@ -1947,13 +2321,13 @@ app.MapGet("/concelier/advisories/{vulnerabilityKey}/replay", async (
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(vulnerabilityKey))
|
||||
{
|
||||
return Results.BadRequest("vulnerabilityKey must be provided.");
|
||||
return ConcelierProblemResultFactory.VulnerabilityKeyRequired(context);
|
||||
}
|
||||
|
||||
var replay = await eventLog.ReplayAsync(vulnerabilityKey.Trim(), asOf, cancellationToken).ConfigureAwait(false);
|
||||
if (replay.Statements.Length == 0 && replay.Conflicts.Length == 0)
|
||||
{
|
||||
return Results.NotFound();
|
||||
return ConcelierProblemResultFactory.VulnerabilityNotFound(context, vulnerabilityKey);
|
||||
}
|
||||
|
||||
var response = new
|
||||
@@ -2309,7 +2683,7 @@ IResult JsonResult<T>(T value, int? statusCode = null)
|
||||
return Results.Content(payload, "application/json", Encoding.UTF8, statusCode);
|
||||
}
|
||||
|
||||
IResult Problem(HttpContext context, string title, int statusCode, string type, string? detail = null, IDictionary<string, object?>? extensions = null)
|
||||
IResult Problem(HttpContext context, string title, int statusCode, string type, string? detail = null, IDictionary<string, object?>? extensions = null, string? errorCode = null)
|
||||
{
|
||||
var traceId = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier;
|
||||
extensions ??= new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
@@ -2322,6 +2696,12 @@ IResult Problem(HttpContext context, string title, int statusCode, string type,
|
||||
extensions["traceId"] = traceId;
|
||||
}
|
||||
|
||||
// Per CONCELIER-WEB-OAS-61-002: Add error code extension for machine-readable errors
|
||||
if (!string.IsNullOrEmpty(errorCode))
|
||||
{
|
||||
extensions["error"] = new { code = errorCode, message = detail ?? title };
|
||||
}
|
||||
|
||||
var problemDetails = new ProblemDetails
|
||||
{
|
||||
Type = type,
|
||||
@@ -3208,7 +3588,7 @@ var concelierTimelineEndpoint = app.MapGet("/obs/concelier/timeline", async (
|
||||
var candidateCursor = cursor ?? context.Request.Headers["Last-Event-ID"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(candidateCursor) && !int.TryParse(candidateCursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out startId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "cursor must be integer" });
|
||||
return ConcelierProblemResultFactory.InvalidCursor(context);
|
||||
}
|
||||
|
||||
var logger = loggerFactory.CreateLogger("ConcelierTimeline");
|
||||
|
||||
Reference in New Issue
Block a user