Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -10,7 +10,8 @@ public sealed record AdvisoryObservationQueryResponse(
|
||||
ImmutableArray<AdvisoryObservation> Observations,
|
||||
AdvisoryObservationLinksetAggregateResponse Linkset,
|
||||
string? NextCursor,
|
||||
bool HasMore);
|
||||
bool HasMore,
|
||||
DataFreshnessInfo? Freshness = null);
|
||||
|
||||
public sealed record AdvisoryObservationLinksetAggregateResponse(
|
||||
ImmutableArray<string> Aliases,
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Staleness metadata for air-gapped deployments.
|
||||
/// Per CONCELIER-WEB-AIRGAP-56-002.
|
||||
/// </summary>
|
||||
public sealed record StalenessMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// When the data was last refreshed from its source.
|
||||
/// </summary>
|
||||
[JsonPropertyName("lastRefreshedAt")]
|
||||
public DateTimeOffset? LastRefreshedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Age of the data in seconds since last refresh.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ageSeconds")]
|
||||
public long? AgeSeconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the data is considered stale based on configured thresholds.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isStale")]
|
||||
public bool IsStale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Staleness threshold in seconds (data older than this is stale).
|
||||
/// </summary>
|
||||
[JsonPropertyName("thresholdSeconds")]
|
||||
public long? ThresholdSeconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable staleness status.
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = "unknown";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a fresh staleness metadata.
|
||||
/// </summary>
|
||||
public static StalenessMetadata Fresh(DateTimeOffset refreshedAt, long thresholdSeconds = 86400)
|
||||
{
|
||||
return new StalenessMetadata
|
||||
{
|
||||
LastRefreshedAt = refreshedAt,
|
||||
AgeSeconds = 0,
|
||||
IsStale = false,
|
||||
ThresholdSeconds = thresholdSeconds,
|
||||
Status = "fresh"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates staleness metadata based on refresh time and threshold.
|
||||
/// </summary>
|
||||
public static StalenessMetadata Compute(
|
||||
DateTimeOffset? lastRefreshedAt,
|
||||
DateTimeOffset now,
|
||||
long thresholdSeconds = 86400)
|
||||
{
|
||||
if (!lastRefreshedAt.HasValue)
|
||||
{
|
||||
return new StalenessMetadata
|
||||
{
|
||||
LastRefreshedAt = null,
|
||||
AgeSeconds = null,
|
||||
IsStale = true,
|
||||
ThresholdSeconds = thresholdSeconds,
|
||||
Status = "unknown"
|
||||
};
|
||||
}
|
||||
|
||||
var age = (long)(now - lastRefreshedAt.Value).TotalSeconds;
|
||||
var isStale = age > thresholdSeconds;
|
||||
|
||||
return new StalenessMetadata
|
||||
{
|
||||
LastRefreshedAt = lastRefreshedAt,
|
||||
AgeSeconds = age,
|
||||
IsStale = isStale,
|
||||
ThresholdSeconds = thresholdSeconds,
|
||||
Status = isStale ? "stale" : "fresh"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bundle provenance metadata for air-gapped deployments.
|
||||
/// Per CONCELIER-WEB-AIRGAP-56-002.
|
||||
/// </summary>
|
||||
public sealed record BundleProvenanceMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Bundle identifier the data originated from.
|
||||
/// </summary>
|
||||
[JsonPropertyName("bundleId")]
|
||||
public string? BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("bundleVersion")]
|
||||
public string? BundleVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source that provided the bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string? SourceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the bundle was imported.
|
||||
/// </summary>
|
||||
[JsonPropertyName("importedAt")]
|
||||
public DateTimeOffset? ImportedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content hash for integrity verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("contentHash")]
|
||||
public string? ContentHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signature status (verified, unverified, unsigned).
|
||||
/// </summary>
|
||||
[JsonPropertyName("signatureStatus")]
|
||||
public string? SignatureStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID used for signing.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signatureKeyId")]
|
||||
public string? SignatureKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this data came from an air-gapped bundle (vs direct ingestion).
|
||||
/// </summary>
|
||||
[JsonPropertyName("isAirGapped")]
|
||||
public bool IsAirGapped { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Combined data freshness information for API responses.
|
||||
/// Per CONCELIER-WEB-AIRGAP-56-002.
|
||||
/// </summary>
|
||||
public sealed record DataFreshnessInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Staleness metadata.
|
||||
/// </summary>
|
||||
[JsonPropertyName("staleness")]
|
||||
public StalenessMetadata? Staleness { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle provenance if data came from an air-gap bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("bundleProvenance")]
|
||||
public BundleProvenanceMetadata? BundleProvenance { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether data is from an air-gapped source.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isAirGapped")]
|
||||
public bool IsAirGapped => BundleProvenance?.IsAirGapped ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// Computed at timestamp.
|
||||
/// </summary>
|
||||
[JsonPropertyName("computedAt")]
|
||||
public DateTimeOffset ComputedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates freshness info for online (non-air-gapped) data.
|
||||
/// </summary>
|
||||
public static DataFreshnessInfo Online(DateTimeOffset now, DateTimeOffset? lastRefreshedAt = null)
|
||||
{
|
||||
return new DataFreshnessInfo
|
||||
{
|
||||
Staleness = StalenessMetadata.Compute(lastRefreshedAt ?? now, now),
|
||||
BundleProvenance = null,
|
||||
ComputedAt = now
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates freshness info for air-gapped data.
|
||||
/// </summary>
|
||||
public static DataFreshnessInfo AirGapped(
|
||||
DateTimeOffset now,
|
||||
string bundleId,
|
||||
string? bundleVersion,
|
||||
string sourceId,
|
||||
DateTimeOffset importedAt,
|
||||
string? contentHash = null,
|
||||
string? signatureStatus = null,
|
||||
long stalenessThresholdSeconds = 86400)
|
||||
{
|
||||
return new DataFreshnessInfo
|
||||
{
|
||||
Staleness = StalenessMetadata.Compute(importedAt, now, stalenessThresholdSeconds),
|
||||
BundleProvenance = new BundleProvenanceMetadata
|
||||
{
|
||||
BundleId = bundleId,
|
||||
BundleVersion = bundleVersion,
|
||||
SourceId = sourceId,
|
||||
ImportedAt = importedAt,
|
||||
ContentHash = contentHash,
|
||||
SignatureStatus = signatureStatus,
|
||||
IsAirGapped = true
|
||||
},
|
||||
ComputedAt = now
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,8 @@ public sealed record LnmLinksetResponse(
|
||||
[property: JsonPropertyName("normalized")] LnmLinksetNormalized? Normalized,
|
||||
[property: JsonPropertyName("cached")] bool Cached,
|
||||
[property: JsonPropertyName("remarks")] IReadOnlyList<string> Remarks,
|
||||
[property: JsonPropertyName("observations")] IReadOnlyList<string> Observations);
|
||||
[property: JsonPropertyName("observations")] IReadOnlyList<string> Observations,
|
||||
[property: JsonPropertyName("freshness")] DataFreshnessInfo? Freshness = null);
|
||||
|
||||
public sealed record LnmLinksetPage(
|
||||
[property: JsonPropertyName("items")] IReadOnlyList<LnmLinksetResponse> Items,
|
||||
|
||||
@@ -62,6 +62,9 @@ public static class ErrorCodes
|
||||
/// <summary>Bundle source not found.</summary>
|
||||
public const string BundleSourceNotFound = "BUNDLE_SOURCE_NOT_FOUND";
|
||||
|
||||
/// <summary>Bundle not found in catalog.</summary>
|
||||
public const string BundleNotFound = "BUNDLE_NOT_FOUND";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// AOC (Aggregation-Only Contract) Errors
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -161,5 +162,118 @@ internal static class AirGapEndpointExtensions
|
||||
var status = sealedModeEnforcer.GetStatus();
|
||||
return Results.Ok(status);
|
||||
});
|
||||
|
||||
// POST /api/v1/concelier/airgap/bundles/{bundleId}/import - Import a bundle with timeline event
|
||||
// Per CONCELIER-WEB-AIRGAP-58-001
|
||||
group.MapPost("/bundles/{bundleId}/import", async (
|
||||
HttpContext context,
|
||||
IBundleCatalogService catalogService,
|
||||
IBundleTimelineEmitter timelineEmitter,
|
||||
IOptionsMonitor<ConcelierOptions> optionsMonitor,
|
||||
string bundleId,
|
||||
[FromBody] BundleImportRequestDto requestDto,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var airGapOptions = optionsMonitor.CurrentValue.AirGap;
|
||||
if (!airGapOptions.Enabled)
|
||||
{
|
||||
return ConcelierProblemResultFactory.AirGapDisabled(context);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(requestDto.TenantId))
|
||||
{
|
||||
return ConcelierProblemResultFactory.RequiredFieldMissing(context, "tenantId");
|
||||
}
|
||||
|
||||
// Find the bundle in the catalog
|
||||
var catalog = await catalogService.GetCatalogAsync(null, 1000, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var bundle = catalog.Entries.FirstOrDefault(e => e.BundleId == bundleId);
|
||||
if (bundle is null)
|
||||
{
|
||||
return ConcelierProblemResultFactory.BundleNotFound(context, bundleId);
|
||||
}
|
||||
|
||||
// Create actor from request or default
|
||||
var actor = new BundleImportActor
|
||||
{
|
||||
Id = requestDto.ActorId ?? context.User?.Identity?.Name ?? "anonymous",
|
||||
Type = requestDto.ActorType ?? "user",
|
||||
DisplayName = requestDto.ActorDisplayName
|
||||
};
|
||||
|
||||
// Create import request
|
||||
var importRequest = new BundleImportRequest
|
||||
{
|
||||
TenantId = requestDto.TenantId,
|
||||
Bundle = bundle,
|
||||
Scope = Enum.TryParse<BundleImportScope>(requestDto.Scope, true, out var scope)
|
||||
? scope
|
||||
: BundleImportScope.Delta,
|
||||
Actor = actor,
|
||||
TraceId = Activity.Current?.TraceId.ToString()
|
||||
};
|
||||
|
||||
// Simulate import (actual import would happen via ingestion pipeline)
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
// TODO: Wire actual bundle import logic here
|
||||
var importStats = new BundleImportStats
|
||||
{
|
||||
TotalItems = bundle.ItemCount,
|
||||
ItemsAdded = bundle.ItemCount,
|
||||
ItemsUpdated = 0,
|
||||
ItemsRemoved = 0,
|
||||
ItemsSkipped = 0,
|
||||
DurationMs = sw.ElapsedMilliseconds,
|
||||
SizeBytes = bundle.SizeBytes
|
||||
};
|
||||
|
||||
var importResult = new BundleImportResult
|
||||
{
|
||||
Success = true,
|
||||
Stats = importStats,
|
||||
EvidenceBundleRef = requestDto.EvidenceBundleRef
|
||||
};
|
||||
|
||||
// Emit timeline event
|
||||
var timelineEvent = await timelineEmitter.EmitImportAsync(importRequest, importResult, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new BundleImportResponseDto
|
||||
{
|
||||
EventId = timelineEvent.EventId,
|
||||
BundleId = bundleId,
|
||||
TenantId = requestDto.TenantId,
|
||||
Stats = importStats,
|
||||
OccurredAt = timelineEvent.OccurredAt
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for bundle import.
|
||||
/// </summary>
|
||||
public sealed record BundleImportRequestDto
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public string? Scope { get; init; }
|
||||
public string? ActorId { get; init; }
|
||||
public string? ActorType { get; init; }
|
||||
public string? ActorDisplayName { get; init; }
|
||||
public string? EvidenceBundleRef { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for bundle import.
|
||||
/// </summary>
|
||||
public sealed record BundleImportResponseDto
|
||||
{
|
||||
public Guid EventId { get; init; }
|
||||
public required string BundleId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required BundleImportStats Stats { get; init; }
|
||||
public DateTimeOffset OccurredAt { get; init; }
|
||||
}
|
||||
|
||||
@@ -2552,7 +2552,8 @@ LnmLinksetResponse ToLnmResponse(
|
||||
bool includeConflicts,
|
||||
bool includeTimeline,
|
||||
bool includeObservations,
|
||||
LinksetObservationSummary summary)
|
||||
LinksetObservationSummary summary,
|
||||
DataFreshnessInfo? freshness = null)
|
||||
{
|
||||
var normalized = linkset.Normalized;
|
||||
var severity = summary.Severity ?? (normalized?.Severities?.FirstOrDefault() is { } severityDict
|
||||
@@ -2607,7 +2608,8 @@ LnmLinksetResponse ToLnmResponse(
|
||||
normalizedDto,
|
||||
Cached: false,
|
||||
Remarks: Array.Empty<string>(),
|
||||
Observations: includeObservations ? linkset.ObservationIds : Array.Empty<string>());
|
||||
Observations: includeObservations ? linkset.ObservationIds : Array.Empty<string>(),
|
||||
Freshness: freshness);
|
||||
}
|
||||
|
||||
string? ExtractSeverity(IReadOnlyDictionary<string, object?> severityDict)
|
||||
|
||||
@@ -199,6 +199,14 @@ public static class ConcelierProblemResultFactory
|
||||
return NotFound(context, ErrorCodes.BundleSourceNotFound, "Bundle source", sourceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 404 Not Found response for bundle not found.
|
||||
/// </summary>
|
||||
public static IResult BundleNotFound(HttpContext context, string? bundleId = null)
|
||||
{
|
||||
return NotFound(context, ErrorCodes.BundleNotFound, "Bundle", bundleId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a generic 404 Not Found response.
|
||||
/// </summary>
|
||||
@@ -316,6 +324,64 @@ public static class ConcelierProblemResultFactory
|
||||
new Dictionary<string, object?> { ["destination"] = destination });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 403 Forbidden response for egress blocked with full payload and remediation.
|
||||
/// Per CONCELIER-WEB-AIRGAP-57-001.
|
||||
/// </summary>
|
||||
public static IResult EgressBlocked(
|
||||
HttpContext context,
|
||||
StellaOps.Concelier.Core.AirGap.SealedModeViolationException exception)
|
||||
{
|
||||
var payload = exception.Payload;
|
||||
var envelope = new ErrorEnvelope
|
||||
{
|
||||
Type = "https://stellaops.org/problems/airgap-egress-blocked",
|
||||
Title = "Egress blocked by sealed mode",
|
||||
Status = StatusCodes.Status403Forbidden,
|
||||
Detail = payload.Reason,
|
||||
Instance = context.Request.Path,
|
||||
TraceId = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier,
|
||||
Error = new ErrorDetail
|
||||
{
|
||||
Code = StellaOps.Concelier.Core.AirGap.Models.AirGapEgressBlockedPayload.ErrorCode,
|
||||
Message = payload.Reason,
|
||||
Target = payload.SourceName,
|
||||
Metadata = new Dictionary<string, object?>
|
||||
{
|
||||
["sourceName"] = payload.SourceName,
|
||||
["destination"] = payload.Destination,
|
||||
["destinationHost"] = payload.DestinationHost,
|
||||
["occurredAt"] = payload.OccurredAt,
|
||||
["wasBlocked"] = payload.WasBlocked,
|
||||
["remediation"] = new
|
||||
{
|
||||
summary = payload.Remediation.Summary,
|
||||
steps = payload.Remediation.Steps.Select(s => new
|
||||
{
|
||||
order = s.Order,
|
||||
action = s.Action,
|
||||
description = s.Description
|
||||
}).ToArray(),
|
||||
configurationHints = payload.Remediation.ConfigurationHints.Select(h => new
|
||||
{
|
||||
key = h.Key,
|
||||
description = h.Description,
|
||||
example = h.Example
|
||||
}).ToArray(),
|
||||
documentationLinks = payload.Remediation.DocumentationLinks.Select(l => new
|
||||
{
|
||||
title = l.Title,
|
||||
url = l.Url
|
||||
}).ToArray()
|
||||
}
|
||||
},
|
||||
HelpUrl = "https://docs.stellaops.org/concelier/airgap/sealed-mode"
|
||||
}
|
||||
};
|
||||
|
||||
return Microsoft.AspNetCore.Http.Results.Json(envelope, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Rate Limiting (429)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: StellaOps Concelier – Link-Not-Merge Policy APIs
|
||||
version: "0.1.0"
|
||||
description: Fact-only advisory/linkset retrieval for Policy Engine consumers.
|
||||
version: "1.0.0"
|
||||
description: |
|
||||
Fact-only advisory/linkset retrieval for Policy Engine consumers.
|
||||
|
||||
## Philosophy
|
||||
Link-Not-Merge (LNM) provides raw advisory data with full provenance:
|
||||
- **Link**: Observations from multiple sources are linked via shared identifiers.
|
||||
- **Not Merge**: Conflicting data is preserved rather than collapsed.
|
||||
- **Surface, Don't Resolve**: Conflicts are clearly marked for consumers.
|
||||
|
||||
## Authentication
|
||||
All endpoints require the `X-Stella-Tenant` header for multi-tenant isolation.
|
||||
|
||||
## Pagination
|
||||
List endpoints support cursor-based pagination with `page` and `pageSize` parameters.
|
||||
Maximum page size is 200 items.
|
||||
|
||||
## Documentation
|
||||
See `/docs/modules/concelier/api/` for detailed examples and conflict resolution strategies.
|
||||
servers:
|
||||
- url: /
|
||||
description: Relative base path (API Gateway rewrites in production).
|
||||
@@ -44,6 +61,65 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PagedLinksets'
|
||||
examples:
|
||||
single-linkset:
|
||||
summary: Single linkset result
|
||||
value:
|
||||
items:
|
||||
- advisoryId: "CVE-2021-23337"
|
||||
source: "nvd"
|
||||
purl: ["pkg:npm/lodash@4.17.20"]
|
||||
cpe: ["cpe:2.3:a:lodash:lodash:4.17.20:*:*:*:*:node.js:*:*"]
|
||||
summary: "Lodash Command Injection vulnerability"
|
||||
publishedAt: "2021-02-15T13:15:00Z"
|
||||
modifiedAt: "2024-08-04T19:16:00Z"
|
||||
severity: "high"
|
||||
provenance:
|
||||
ingestedAt: "2025-11-20T10:30:00Z"
|
||||
connectorId: "nvd-osv-connector"
|
||||
evidenceHash: "sha256:a1b2c3d4e5f6"
|
||||
conflicts: []
|
||||
cached: false
|
||||
page: 1
|
||||
pageSize: 50
|
||||
total: 1
|
||||
with-conflicts:
|
||||
summary: Linkset with severity conflict
|
||||
value:
|
||||
items:
|
||||
- advisoryId: "CVE-2024-1234"
|
||||
source: "aggregated"
|
||||
purl: ["pkg:npm/example@1.0.0"]
|
||||
cpe: []
|
||||
severity: "high"
|
||||
provenance:
|
||||
ingestedAt: "2025-11-20T10:30:00Z"
|
||||
connectorId: "multi-source"
|
||||
conflicts:
|
||||
- field: "severity"
|
||||
reason: "severity-mismatch"
|
||||
observedValue: "critical"
|
||||
observedAt: "2025-11-18T08:00:00Z"
|
||||
evidenceHash: "sha256:conflict-hash"
|
||||
cached: false
|
||||
page: 1
|
||||
pageSize: 50
|
||||
total: 1
|
||||
"400":
|
||||
description: Invalid request parameters
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
example:
|
||||
type: "https://stellaops.io/errors/validation-failed"
|
||||
title: "Validation Failed"
|
||||
status: 400
|
||||
detail: "The 'pageSize' parameter exceeds the maximum allowed value."
|
||||
error:
|
||||
code: "ERR_PAGE_SIZE_EXCEEDED"
|
||||
message: "Page size must be between 1 and 200."
|
||||
target: "pageSize"
|
||||
/v1/lnm/linksets/{advisoryId}:
|
||||
get:
|
||||
summary: Get linkset by advisory ID
|
||||
@@ -275,3 +351,63 @@ components:
|
||||
event: { type: string }
|
||||
at: { type: string, format: date-time }
|
||||
evidenceHash: { type: string }
|
||||
ErrorEnvelope:
|
||||
type: object
|
||||
description: RFC 7807 Problem Details with StellaOps extensions
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
format: uri
|
||||
description: URI identifying the problem type
|
||||
title:
|
||||
type: string
|
||||
description: Short, human-readable summary
|
||||
status:
|
||||
type: integer
|
||||
description: HTTP status code
|
||||
detail:
|
||||
type: string
|
||||
description: Specific explanation of the problem
|
||||
instance:
|
||||
type: string
|
||||
format: uri
|
||||
description: URI of the specific occurrence
|
||||
traceId:
|
||||
type: string
|
||||
description: Distributed trace identifier
|
||||
error:
|
||||
$ref: '#/components/schemas/ErrorDetail'
|
||||
ErrorDetail:
|
||||
type: object
|
||||
description: Machine-readable error information
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
description: Machine-readable error code (e.g., ERR_VALIDATION_FAILED)
|
||||
message:
|
||||
type: string
|
||||
description: Human-readable error message
|
||||
target:
|
||||
type: string
|
||||
description: Field or resource that caused the error
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: Additional contextual data
|
||||
innerErrors:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ValidationError'
|
||||
description: Nested validation errors
|
||||
ValidationError:
|
||||
type: object
|
||||
properties:
|
||||
field:
|
||||
type: string
|
||||
description: Field path (e.g., "data.severity")
|
||||
code:
|
||||
type: string
|
||||
description: Error code for this field
|
||||
message:
|
||||
type: string
|
||||
description: Human-readable message
|
||||
|
||||
Reference in New Issue
Block a user