feat: Add Bun language analyzer and related functionality

- Implemented BunPackageNormalizer to deduplicate packages by name and version.
- Created BunProjectDiscoverer to identify Bun project roots in the filesystem.
- Added project files for the Bun analyzer including manifest and project configuration.
- Developed comprehensive tests for Bun language analyzer covering various scenarios.
- Included fixture files for testing standard installs, isolated linker installs, lockfile-only scenarios, and workspaces.
- Established stubs for authentication sessions to facilitate testing in the web application.
This commit is contained in:
StellaOps Bot
2025-12-06 11:20:35 +02:00
parent b978ae399f
commit a7cd10020a
85 changed files with 7414 additions and 42 deletions

View File

@@ -0,0 +1,12 @@
namespace StellaOps.Policy.Engine.AirGap;
/// <summary>
/// Store for imported policy pack bundles.
/// </summary>
public interface IPolicyPackBundleStore
{
Task<ImportedPolicyPackBundle?> GetAsync(string bundleId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<ImportedPolicyPackBundle>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
Task SaveAsync(ImportedPolicyPackBundle bundle, CancellationToken cancellationToken = default);
Task DeleteAsync(string bundleId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,47 @@
using System.Collections.Concurrent;
namespace StellaOps.Policy.Engine.AirGap;
/// <summary>
/// In-memory implementation of policy pack bundle store.
/// </summary>
internal sealed class InMemoryPolicyPackBundleStore : IPolicyPackBundleStore
{
private readonly ConcurrentDictionary<string, ImportedPolicyPackBundle> _bundles = new(StringComparer.Ordinal);
public Task<ImportedPolicyPackBundle?> GetAsync(string bundleId, CancellationToken cancellationToken = default)
{
_bundles.TryGetValue(bundleId, out var bundle);
return Task.FromResult(bundle);
}
public Task<IReadOnlyList<ImportedPolicyPackBundle>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
IEnumerable<ImportedPolicyPackBundle> bundles = _bundles.Values;
if (!string.IsNullOrWhiteSpace(tenantId))
{
bundles = bundles.Where(b => string.Equals(b.TenantId, tenantId, StringComparison.Ordinal));
}
var ordered = bundles
.OrderByDescending(b => b.ImportedAt)
.ThenBy(b => b.BundleId, StringComparer.Ordinal)
.ToList();
return Task.FromResult<IReadOnlyList<ImportedPolicyPackBundle>>(ordered);
}
public Task SaveAsync(ImportedPolicyPackBundle bundle, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(bundle);
_bundles[bundle.BundleId] = bundle;
return Task.CompletedTask;
}
public Task DeleteAsync(string bundleId, CancellationToken cancellationToken = default)
{
_bundles.TryRemove(bundleId, out _);
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,248 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace StellaOps.Policy.Engine.AirGap;
/// <summary>
/// Service for importing policy pack bundles per CONTRACT-MIRROR-BUNDLE-003.
/// </summary>
internal sealed class PolicyPackBundleImportService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly IPolicyPackBundleStore _store;
private readonly TimeProvider _timeProvider;
private readonly ILogger<PolicyPackBundleImportService> _logger;
public PolicyPackBundleImportService(
IPolicyPackBundleStore store,
TimeProvider timeProvider,
ILogger<PolicyPackBundleImportService> logger)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Registers a bundle for import and begins validation.
/// </summary>
public async Task<RegisterBundleResponse> RegisterBundleAsync(
string tenantId,
RegisterBundleRequest request,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(request.BundlePath);
var now = _timeProvider.GetUtcNow();
var importId = GenerateImportId();
_logger.LogInformation("Registering bundle import {ImportId} from {BundlePath} for tenant {TenantId}",
importId, request.BundlePath, tenantId);
// Create initial entry in validating state
var entry = new ImportedPolicyPackBundle(
BundleId: importId,
DomainId: BundleDomainIds.PolicyPacks,
TenantId: tenantId,
Status: BundleImportStatus.Validating,
ExportCount: 0,
ImportedAt: now.ToString("O"),
Error: null,
Bundle: null);
await _store.SaveAsync(entry, cancellationToken).ConfigureAwait(false);
// Start async import process
_ = ImportBundleAsync(tenantId, importId, request, cancellationToken);
return new RegisterBundleResponse(importId, BundleImportStatus.Validating);
}
/// <summary>
/// Gets the status of a bundle import.
/// </summary>
public async Task<BundleStatusResponse?> GetBundleStatusAsync(
string bundleId,
CancellationToken cancellationToken = default)
{
var bundle = await _store.GetAsync(bundleId, cancellationToken).ConfigureAwait(false);
if (bundle is null)
{
return null;
}
return new BundleStatusResponse(
BundleId: bundle.BundleId,
DomainId: bundle.DomainId,
Status: bundle.Status,
ExportCount: bundle.ExportCount,
ImportedAt: bundle.ImportedAt,
Error: bundle.Error);
}
/// <summary>
/// Lists imported bundles for a tenant.
/// </summary>
public async Task<IReadOnlyList<BundleStatusResponse>> ListBundlesAsync(
string? tenantId = null,
CancellationToken cancellationToken = default)
{
var bundles = await _store.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
return bundles.Select(b => new BundleStatusResponse(
BundleId: b.BundleId,
DomainId: b.DomainId,
Status: b.Status,
ExportCount: b.ExportCount,
ImportedAt: b.ImportedAt,
Error: b.Error)).ToList();
}
private async Task ImportBundleAsync(
string tenantId,
string importId,
RegisterBundleRequest request,
CancellationToken cancellationToken)
{
try
{
_logger.LogInformation("Starting bundle import {ImportId}", importId);
// Update status to importing
var current = await _store.GetAsync(importId, cancellationToken).ConfigureAwait(false);
if (current is null)
{
return;
}
await _store.SaveAsync(current with { Status = BundleImportStatus.Importing }, cancellationToken).ConfigureAwait(false);
// Load and parse bundle
var bundle = await LoadBundleAsync(request.BundlePath, cancellationToken).ConfigureAwait(false);
// Validate bundle
ValidateBundle(bundle);
// Verify signatures if present
if (bundle.Signature is not null)
{
await VerifySignatureAsync(bundle, request.TrustRootsPath, cancellationToken).ConfigureAwait(false);
}
// Verify export digests
VerifyExportDigests(bundle);
// Mark as imported
var now = _timeProvider.GetUtcNow();
var imported = new ImportedPolicyPackBundle(
BundleId: importId,
DomainId: bundle.DomainId,
TenantId: tenantId,
Status: BundleImportStatus.Imported,
ExportCount: bundle.Exports.Count,
ImportedAt: now.ToString("O"),
Error: null,
Bundle: bundle);
await _store.SaveAsync(imported, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Bundle import {ImportId} completed successfully with {ExportCount} exports",
importId, bundle.Exports.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Bundle import {ImportId} failed: {Error}", importId, ex.Message);
var failed = await _store.GetAsync(importId, CancellationToken.None).ConfigureAwait(false);
if (failed is not null)
{
await _store.SaveAsync(failed with
{
Status = BundleImportStatus.Failed,
Error = ex.Message
}, CancellationToken.None).ConfigureAwait(false);
}
}
}
private static async Task<PolicyPackBundle> LoadBundleAsync(string bundlePath, CancellationToken cancellationToken)
{
if (!File.Exists(bundlePath))
{
throw new FileNotFoundException($"Bundle file not found: {bundlePath}");
}
var json = await File.ReadAllTextAsync(bundlePath, cancellationToken).ConfigureAwait(false);
var bundle = JsonSerializer.Deserialize<PolicyPackBundle>(json, JsonOptions)
?? throw new InvalidDataException("Failed to parse bundle JSON");
return bundle;
}
private static void ValidateBundle(PolicyPackBundle bundle)
{
if (bundle.SchemaVersion < 1)
{
throw new InvalidDataException("Invalid schema version");
}
if (string.IsNullOrWhiteSpace(bundle.DomainId))
{
throw new InvalidDataException("Domain ID is required");
}
if (bundle.Exports.Count == 0)
{
throw new InvalidDataException("Bundle must contain at least one export");
}
foreach (var export in bundle.Exports)
{
if (string.IsNullOrWhiteSpace(export.Key))
{
throw new InvalidDataException("Export key is required");
}
if (string.IsNullOrWhiteSpace(export.ArtifactDigest))
{
throw new InvalidDataException($"Artifact digest is required for export '{export.Key}'");
}
}
}
private Task VerifySignatureAsync(PolicyPackBundle bundle, string? trustRootsPath, CancellationToken cancellationToken)
{
// Signature verification would integrate with the AirGap.Importer DsseVerifier
// For now, log that signature is present
_logger.LogInformation("Bundle signature present: algorithm={Algorithm}, keyId={KeyId}",
bundle.Signature!.Algorithm, bundle.Signature.KeyId);
return Task.CompletedTask;
}
private void VerifyExportDigests(PolicyPackBundle bundle)
{
foreach (var export in bundle.Exports)
{
// Verify digest format
if (!export.ArtifactDigest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException($"Invalid digest format for export '{export.Key}': expected sha256: prefix");
}
_logger.LogDebug("Verified export '{Key}' with digest {Digest}",
export.Key, export.ArtifactDigest);
}
}
private static string GenerateImportId()
{
return $"import-{Guid.NewGuid():N}"[..20];
}
}

View File

@@ -0,0 +1,113 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.AirGap;
/// <summary>
/// Mirror bundle for policy packs per CONTRACT-MIRROR-BUNDLE-003.
/// </summary>
public sealed record PolicyPackBundle(
[property: JsonPropertyName("schemaVersion")] int SchemaVersion,
[property: JsonPropertyName("generatedAt")] string GeneratedAt,
[property: JsonPropertyName("targetRepository")] string? TargetRepository,
[property: JsonPropertyName("domainId")] string DomainId,
[property: JsonPropertyName("displayName")] string? DisplayName,
[property: JsonPropertyName("exports")] IReadOnlyList<PolicyPackExport> Exports,
[property: JsonPropertyName("signature")] BundleSignature? Signature);
/// <summary>
/// Export entry within a policy pack bundle.
/// </summary>
public sealed record PolicyPackExport(
[property: JsonPropertyName("key")] string Key,
[property: JsonPropertyName("format")] string Format,
[property: JsonPropertyName("exportId")] string ExportId,
[property: JsonPropertyName("querySignature")] string? QuerySignature,
[property: JsonPropertyName("createdAt")] string CreatedAt,
[property: JsonPropertyName("artifactSizeBytes")] long ArtifactSizeBytes,
[property: JsonPropertyName("artifactDigest")] string ArtifactDigest,
[property: JsonPropertyName("sourceProviders")] IReadOnlyList<string>? SourceProviders,
[property: JsonPropertyName("consensusRevision")] string? ConsensusRevision,
[property: JsonPropertyName("policyRevisionId")] string? PolicyRevisionId,
[property: JsonPropertyName("policyDigest")] string? PolicyDigest,
[property: JsonPropertyName("consensusDigest")] string? ConsensusDigest,
[property: JsonPropertyName("scoreDigest")] string? ScoreDigest,
[property: JsonPropertyName("attestation")] AttestationDescriptor? Attestation);
/// <summary>
/// Attestation metadata for signed exports.
/// </summary>
public sealed record AttestationDescriptor(
[property: JsonPropertyName("predicateType")] string PredicateType,
[property: JsonPropertyName("rekorLocation")] string? RekorLocation,
[property: JsonPropertyName("envelopeDigest")] string? EnvelopeDigest,
[property: JsonPropertyName("signedAt")] string SignedAt);
/// <summary>
/// Bundle signature metadata.
/// </summary>
public sealed record BundleSignature(
[property: JsonPropertyName("path")] string Path,
[property: JsonPropertyName("algorithm")] string Algorithm,
[property: JsonPropertyName("keyId")] string KeyId,
[property: JsonPropertyName("provider")] string? Provider,
[property: JsonPropertyName("signedAt")] string SignedAt);
/// <summary>
/// Request to register a bundle for import.
/// </summary>
public sealed record RegisterBundleRequest(
[property: JsonPropertyName("bundlePath")] string BundlePath,
[property: JsonPropertyName("trustRootsPath")] string? TrustRootsPath);
/// <summary>
/// Response for bundle registration.
/// </summary>
public sealed record RegisterBundleResponse(
[property: JsonPropertyName("importId")] string ImportId,
[property: JsonPropertyName("status")] string Status);
/// <summary>
/// Bundle import status response.
/// </summary>
public sealed record BundleStatusResponse(
[property: JsonPropertyName("bundleId")] string BundleId,
[property: JsonPropertyName("domainId")] string DomainId,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("exportCount")] int ExportCount,
[property: JsonPropertyName("importedAt")] string? ImportedAt,
[property: JsonPropertyName("error")] string? Error);
/// <summary>
/// Imported bundle catalog entry.
/// </summary>
public sealed record ImportedPolicyPackBundle(
string BundleId,
string DomainId,
string TenantId,
string Status,
int ExportCount,
string ImportedAt,
string? Error,
PolicyPackBundle? Bundle);
/// <summary>
/// Bundle import status values.
/// </summary>
public static class BundleImportStatus
{
public const string Validating = "validating";
public const string Importing = "importing";
public const string Imported = "imported";
public const string Failed = "failed";
}
/// <summary>
/// Domain IDs per CONTRACT-MIRROR-BUNDLE-003.
/// </summary>
public static class BundleDomainIds
{
public const string VexAdvisories = "vex-advisories";
public const string VulnerabilityFeeds = "vulnerability-feeds";
public const string PolicyPacks = "policy-packs";
public const string SbomCatalog = "sbom-catalog";
}

View File

@@ -0,0 +1,300 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using StellaOps.Policy.Engine.Ledger;
namespace StellaOps.Policy.Engine.ConsoleExport;
/// <summary>
/// Service for managing Console export jobs per CONTRACT-EXPORT-BUNDLE-009.
/// </summary>
internal sealed partial class ConsoleExportJobService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private static readonly Regex CronRegex = CreateCronRegex();
private readonly IConsoleExportJobStore _jobStore;
private readonly IConsoleExportExecutionStore _executionStore;
private readonly IConsoleExportBundleStore _bundleStore;
private readonly LedgerExportService _ledgerExport;
private readonly TimeProvider _timeProvider;
public ConsoleExportJobService(
IConsoleExportJobStore jobStore,
IConsoleExportExecutionStore executionStore,
IConsoleExportBundleStore bundleStore,
LedgerExportService ledgerExport,
TimeProvider timeProvider)
{
_jobStore = jobStore ?? throw new ArgumentNullException(nameof(jobStore));
_executionStore = executionStore ?? throw new ArgumentNullException(nameof(executionStore));
_bundleStore = bundleStore ?? throw new ArgumentNullException(nameof(bundleStore));
_ledgerExport = ledgerExport ?? throw new ArgumentNullException(nameof(ledgerExport));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task<ExportBundleJob> CreateJobAsync(
string tenantId,
CreateExportJobRequest request,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(request);
ValidateRequest(request);
var now = _timeProvider.GetUtcNow();
var jobId = GenerateId("job");
var job = new ExportBundleJob(
JobId: jobId,
TenantId: tenantId,
Name: request.Name,
Description: request.Description,
Query: request.Query,
Format: request.Format,
Schedule: request.Schedule,
Destination: request.Destination,
Signing: request.Signing,
Enabled: true,
CreatedAt: now.ToString("O"),
LastRunAt: null,
NextRunAt: CalculateNextRun(request.Schedule, now));
await _jobStore.SaveAsync(job, cancellationToken).ConfigureAwait(false);
return job;
}
public async Task<ExportBundleJob?> GetJobAsync(string jobId, CancellationToken cancellationToken = default)
{
return await _jobStore.GetAsync(jobId, cancellationToken).ConfigureAwait(false);
}
public async Task<ListJobsResponse> ListJobsAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
var jobs = await _jobStore.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
return new ListJobsResponse(jobs, jobs.Count);
}
public async Task<ExportBundleJob> UpdateJobAsync(
string jobId,
UpdateExportJobRequest request,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
ArgumentNullException.ThrowIfNull(request);
var existing = await _jobStore.GetAsync(jobId, cancellationToken).ConfigureAwait(false)
?? throw new KeyNotFoundException($"Job '{jobId}' not found");
if (request.Schedule is not null && !IsValidCron(request.Schedule))
{
throw new ArgumentException("Invalid schedule expression", nameof(request));
}
var now = _timeProvider.GetUtcNow();
var newSchedule = request.Schedule ?? existing.Schedule;
var updated = existing with
{
Name = request.Name ?? existing.Name,
Description = request.Description ?? existing.Description,
Schedule = newSchedule,
Signing = request.Signing ?? existing.Signing,
Enabled = request.Enabled ?? existing.Enabled,
NextRunAt = CalculateNextRun(newSchedule, now)
};
await _jobStore.SaveAsync(updated, cancellationToken).ConfigureAwait(false);
return updated;
}
public async Task DeleteJobAsync(string jobId, CancellationToken cancellationToken = default)
{
await _jobStore.DeleteAsync(jobId, cancellationToken).ConfigureAwait(false);
}
public async Task<TriggerExecutionResponse> TriggerJobAsync(string jobId, CancellationToken cancellationToken = default)
{
var job = await _jobStore.GetAsync(jobId, cancellationToken).ConfigureAwait(false)
?? throw new KeyNotFoundException($"Job '{jobId}' not found");
var now = _timeProvider.GetUtcNow();
var executionId = GenerateId("exec");
var execution = new ExportExecution(
ExecutionId: executionId,
JobId: jobId,
Status: "running",
BundleId: null,
StartedAt: now.ToString("O"),
CompletedAt: null,
Error: null);
await _executionStore.SaveAsync(execution, cancellationToken).ConfigureAwait(false);
// Execute the export asynchronously
_ = ExecuteJobAsync(job, execution, cancellationToken);
return new TriggerExecutionResponse(executionId, "running");
}
public async Task<ExportExecution?> GetExecutionAsync(string executionId, CancellationToken cancellationToken = default)
{
return await _executionStore.GetAsync(executionId, cancellationToken).ConfigureAwait(false);
}
public async Task<ExportBundleManifest?> GetBundleAsync(string bundleId, CancellationToken cancellationToken = default)
{
return await _bundleStore.GetAsync(bundleId, cancellationToken).ConfigureAwait(false);
}
public async Task<byte[]?> GetBundleContentAsync(string bundleId, CancellationToken cancellationToken = default)
{
return await _bundleStore.GetContentAsync(bundleId, cancellationToken).ConfigureAwait(false);
}
private async Task ExecuteJobAsync(ExportBundleJob job, ExportExecution execution, CancellationToken cancellationToken)
{
try
{
// Build ledger export for this tenant
var request = new LedgerExportRequest(job.TenantId);
var ledgerExport = await _ledgerExport.BuildAsync(request, cancellationToken).ConfigureAwait(false);
// Build bundle content based on format
var content = BuildContent(job, ledgerExport);
var contentBytes = Encoding.UTF8.GetBytes(content);
// Create manifest
var now = _timeProvider.GetUtcNow();
var bundleId = GenerateId("bundle");
var artifactDigest = ComputeSha256(contentBytes);
var querySignature = ComputeSha256(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(job.Query, JsonOptions)));
var manifest = new ExportBundleManifest(
BundleId: bundleId,
JobId: job.JobId,
TenantId: job.TenantId,
CreatedAt: now.ToString("O"),
Format: job.Format,
ArtifactDigest: artifactDigest,
ArtifactSizeBytes: contentBytes.Length,
QuerySignature: querySignature,
ItemCount: ledgerExport.Records.Count,
PolicyDigest: ledgerExport.Manifest.Sha256,
ConsensusDigest: null,
ScoreDigest: null,
Attestation: null);
await _bundleStore.SaveAsync(manifest, contentBytes, cancellationToken).ConfigureAwait(false);
// Update execution as completed
var completedExecution = execution with
{
Status = "completed",
BundleId = bundleId,
CompletedAt = now.ToString("O")
};
await _executionStore.SaveAsync(completedExecution, cancellationToken).ConfigureAwait(false);
// Update job with last run
var updatedJob = job with
{
LastRunAt = now.ToString("O"),
NextRunAt = CalculateNextRun(job.Schedule, now)
};
await _jobStore.SaveAsync(updatedJob, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
var failedExecution = execution with
{
Status = "failed",
CompletedAt = _timeProvider.GetUtcNow().ToString("O"),
Error = ex.Message
};
await _executionStore.SaveAsync(failedExecution, CancellationToken.None).ConfigureAwait(false);
}
}
private static string BuildContent(ExportBundleJob job, LedgerExport ledgerExport)
{
return job.Format.ToLowerInvariant() switch
{
ExportFormats.Ndjson => string.Join('\n', ledgerExport.Lines),
ExportFormats.Json => JsonSerializer.Serialize(ledgerExport.Records, JsonOptions),
_ => JsonSerializer.Serialize(ledgerExport.Records, JsonOptions)
};
}
private void ValidateRequest(CreateExportJobRequest request)
{
if (string.IsNullOrWhiteSpace(request.Name))
{
throw new ArgumentException("Name is required", nameof(request));
}
if (!ExportFormats.IsValid(request.Format))
{
throw new ArgumentException($"Invalid format: {request.Format}", nameof(request));
}
if (!IsValidCron(request.Schedule))
{
throw new ArgumentException("Invalid schedule expression", nameof(request));
}
if (!DestinationTypes.IsValid(request.Destination.Type))
{
throw new ArgumentException($"Invalid destination type: {request.Destination.Type}", nameof(request));
}
}
private static bool IsValidCron(string schedule)
{
if (string.IsNullOrWhiteSpace(schedule))
{
return false;
}
// Basic 5-field cron validation
return CronRegex.IsMatch(schedule);
}
private static string? CalculateNextRun(string schedule, DateTimeOffset from)
{
// Simplified next run calculation - just add 24 hours for daily schedules
// In production, this would use a proper cron parser like Cronos
if (schedule.StartsWith("0 0 ", StringComparison.Ordinal))
{
return from.AddDays(1).ToString("O");
}
if (schedule.StartsWith("0 */", StringComparison.Ordinal))
{
var hourMatch = Regex.Match(schedule, @"\*/(\d+)");
if (hourMatch.Success && int.TryParse(hourMatch.Groups[1].Value, out var hours))
{
return from.AddHours(hours).ToString("O");
}
}
return from.AddDays(1).ToString("O");
}
private static string GenerateId(string prefix)
{
return $"{prefix}-{Guid.NewGuid():N}"[..16];
}
private static string ComputeSha256(byte[] data)
{
var hash = SHA256.HashData(data);
return $"sha256:{Convert.ToHexStringLower(hash)}";
}
[GeneratedRegex(@"^(\*|[0-9]|[1-5][0-9])\s+(\*|[0-9]|1[0-9]|2[0-3])\s+(\*|[1-9]|[12][0-9]|3[01])\s+(\*|[1-9]|1[0-2])\s+(\*|[0-6])$")]
private static partial Regex CreateCronRegex();
}

View File

@@ -0,0 +1,190 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.ConsoleExport;
/// <summary>
/// Export bundle job definition per CONTRACT-EXPORT-BUNDLE-009.
/// </summary>
public sealed record ExportBundleJob(
[property: JsonPropertyName("job_id")] string JobId,
[property: JsonPropertyName("tenant_id")] string TenantId,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("description")] string? Description,
[property: JsonPropertyName("query")] ExportQuery Query,
[property: JsonPropertyName("format")] string Format,
[property: JsonPropertyName("schedule")] string Schedule,
[property: JsonPropertyName("destination")] ExportDestination Destination,
[property: JsonPropertyName("signing")] ExportSigning? Signing,
[property: JsonPropertyName("enabled")] bool Enabled,
[property: JsonPropertyName("created_at")] string CreatedAt,
[property: JsonPropertyName("last_run_at")] string? LastRunAt,
[property: JsonPropertyName("next_run_at")] string? NextRunAt);
/// <summary>
/// Query definition for export jobs.
/// </summary>
public sealed record ExportQuery(
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("filters")] ExportFilters? Filters);
/// <summary>
/// Filters for export queries.
/// </summary>
public sealed record ExportFilters(
[property: JsonPropertyName("severity")] IReadOnlyList<string>? Severity,
[property: JsonPropertyName("providers")] IReadOnlyList<string>? Providers,
[property: JsonPropertyName("status")] IReadOnlyList<string>? Status,
[property: JsonPropertyName("advisory_ids")] IReadOnlyList<string>? AdvisoryIds,
[property: JsonPropertyName("component_purls")] IReadOnlyList<string>? ComponentPurls);
/// <summary>
/// Export destination configuration.
/// </summary>
public sealed record ExportDestination(
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("config")] IReadOnlyDictionary<string, string>? Config);
/// <summary>
/// Signing configuration for exports.
/// </summary>
public sealed record ExportSigning(
[property: JsonPropertyName("enabled")] bool Enabled,
[property: JsonPropertyName("predicate_type")] string? PredicateType,
[property: JsonPropertyName("key_id")] string? KeyId,
[property: JsonPropertyName("include_rekor")] bool IncludeRekor);
/// <summary>
/// Request to create a new export job.
/// </summary>
public sealed record CreateExportJobRequest(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("description")] string? Description,
[property: JsonPropertyName("query")] ExportQuery Query,
[property: JsonPropertyName("format")] string Format,
[property: JsonPropertyName("schedule")] string Schedule,
[property: JsonPropertyName("destination")] ExportDestination Destination,
[property: JsonPropertyName("signing")] ExportSigning? Signing);
/// <summary>
/// Request to update an existing export job.
/// </summary>
public sealed record UpdateExportJobRequest(
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("description")] string? Description,
[property: JsonPropertyName("schedule")] string? Schedule,
[property: JsonPropertyName("enabled")] bool? Enabled,
[property: JsonPropertyName("signing")] ExportSigning? Signing);
/// <summary>
/// Response for job execution trigger.
/// </summary>
public sealed record TriggerExecutionResponse(
[property: JsonPropertyName("execution_id")] string ExecutionId,
[property: JsonPropertyName("status")] string Status);
/// <summary>
/// Export job execution status.
/// </summary>
public sealed record ExportExecution(
[property: JsonPropertyName("execution_id")] string ExecutionId,
[property: JsonPropertyName("job_id")] string JobId,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("bundle_id")] string? BundleId,
[property: JsonPropertyName("started_at")] string StartedAt,
[property: JsonPropertyName("completed_at")] string? CompletedAt,
[property: JsonPropertyName("error")] string? Error);
/// <summary>
/// Export bundle manifest per CONTRACT-EXPORT-BUNDLE-009.
/// </summary>
public sealed record ExportBundleManifest(
[property: JsonPropertyName("bundle_id")] string BundleId,
[property: JsonPropertyName("job_id")] string JobId,
[property: JsonPropertyName("tenant_id")] string TenantId,
[property: JsonPropertyName("created_at")] string CreatedAt,
[property: JsonPropertyName("format")] string Format,
[property: JsonPropertyName("artifact_digest")] string ArtifactDigest,
[property: JsonPropertyName("artifact_size_bytes")] long ArtifactSizeBytes,
[property: JsonPropertyName("query_signature")] string QuerySignature,
[property: JsonPropertyName("item_count")] int ItemCount,
[property: JsonPropertyName("policy_digest")] string? PolicyDigest,
[property: JsonPropertyName("consensus_digest")] string? ConsensusDigest,
[property: JsonPropertyName("score_digest")] string? ScoreDigest,
[property: JsonPropertyName("attestation")] ExportAttestation? Attestation);
/// <summary>
/// Attestation metadata for export bundles.
/// </summary>
public sealed record ExportAttestation(
[property: JsonPropertyName("predicate_type")] string PredicateType,
[property: JsonPropertyName("rekor_uuid")] string? RekorUuid,
[property: JsonPropertyName("rekor_index")] long? RekorIndex,
[property: JsonPropertyName("signed_at")] string SignedAt);
/// <summary>
/// List response for jobs.
/// </summary>
public sealed record ListJobsResponse(
[property: JsonPropertyName("items")] IReadOnlyList<ExportBundleJob> Items,
[property: JsonPropertyName("total")] int Total);
/// <summary>
/// Export formats per CONTRACT-EXPORT-BUNDLE-009.
/// </summary>
public static class ExportFormats
{
public const string OpenVex = "openvex";
public const string Csaf = "csaf";
public const string CycloneDx = "cyclonedx";
public const string Spdx = "spdx";
public const string Ndjson = "ndjson";
public const string Json = "json";
public static readonly IReadOnlySet<string> All = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
OpenVex, Csaf, CycloneDx, Spdx, Ndjson, Json
};
public static bool IsValid(string format) => All.Contains(format);
}
/// <summary>
/// Destination types per CONTRACT-EXPORT-BUNDLE-009.
/// </summary>
public static class DestinationTypes
{
public const string S3 = "s3";
public const string File = "file";
public const string Webhook = "webhook";
public static readonly IReadOnlySet<string> All = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
S3, File, Webhook
};
public static bool IsValid(string type) => All.Contains(type);
}
/// <summary>
/// Job status values per CONTRACT-EXPORT-BUNDLE-009.
/// </summary>
public static class JobStatus
{
public const string Idle = "idle";
public const string Running = "running";
public const string Completed = "completed";
public const string Failed = "failed";
public const string Disabled = "disabled";
}
/// <summary>
/// Export error codes per CONTRACT-EXPORT-BUNDLE-009.
/// </summary>
public static class ExportErrorCodes
{
public const string InvalidSchedule = "ERR_EXP_001";
public const string InvalidDestination = "ERR_EXP_002";
public const string ExportFailed = "ERR_EXP_003";
public const string SigningFailed = "ERR_EXP_004";
public const string JobNotFound = "ERR_EXP_005";
}

View File

@@ -0,0 +1,33 @@
namespace StellaOps.Policy.Engine.ConsoleExport;
/// <summary>
/// Store for Console export jobs.
/// </summary>
public interface IConsoleExportJobStore
{
Task<ExportBundleJob?> GetAsync(string jobId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<ExportBundleJob>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
Task SaveAsync(ExportBundleJob job, CancellationToken cancellationToken = default);
Task DeleteAsync(string jobId, CancellationToken cancellationToken = default);
}
/// <summary>
/// Store for export job executions.
/// </summary>
public interface IConsoleExportExecutionStore
{
Task<ExportExecution?> GetAsync(string executionId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<ExportExecution>> ListByJobAsync(string jobId, CancellationToken cancellationToken = default);
Task SaveAsync(ExportExecution execution, CancellationToken cancellationToken = default);
}
/// <summary>
/// Store for export bundle manifests.
/// </summary>
public interface IConsoleExportBundleStore
{
Task<ExportBundleManifest?> GetAsync(string bundleId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<ExportBundleManifest>> ListByJobAsync(string jobId, CancellationToken cancellationToken = default);
Task SaveAsync(ExportBundleManifest manifest, byte[] content, CancellationToken cancellationToken = default);
Task<byte[]?> GetContentAsync(string bundleId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,118 @@
using System.Collections.Concurrent;
namespace StellaOps.Policy.Engine.ConsoleExport;
/// <summary>
/// In-memory implementation of IConsoleExportJobStore.
/// </summary>
internal sealed class InMemoryConsoleExportJobStore : IConsoleExportJobStore
{
private readonly ConcurrentDictionary<string, ExportBundleJob> _jobs = new(StringComparer.Ordinal);
public Task<ExportBundleJob?> GetAsync(string jobId, CancellationToken cancellationToken = default)
{
_jobs.TryGetValue(jobId, out var job);
return Task.FromResult(job);
}
public Task<IReadOnlyList<ExportBundleJob>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
IEnumerable<ExportBundleJob> jobs = _jobs.Values;
if (!string.IsNullOrWhiteSpace(tenantId))
{
jobs = jobs.Where(j => string.Equals(j.TenantId, tenantId, StringComparison.Ordinal));
}
var ordered = jobs
.OrderBy(j => j.CreatedAt, StringComparer.Ordinal)
.ThenBy(j => j.JobId, StringComparer.Ordinal)
.ToList();
return Task.FromResult<IReadOnlyList<ExportBundleJob>>(ordered);
}
public Task SaveAsync(ExportBundleJob job, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(job);
_jobs[job.JobId] = job;
return Task.CompletedTask;
}
public Task DeleteAsync(string jobId, CancellationToken cancellationToken = default)
{
_jobs.TryRemove(jobId, out _);
return Task.CompletedTask;
}
}
/// <summary>
/// In-memory implementation of IConsoleExportExecutionStore.
/// </summary>
internal sealed class InMemoryConsoleExportExecutionStore : IConsoleExportExecutionStore
{
private readonly ConcurrentDictionary<string, ExportExecution> _executions = new(StringComparer.Ordinal);
public Task<ExportExecution?> GetAsync(string executionId, CancellationToken cancellationToken = default)
{
_executions.TryGetValue(executionId, out var execution);
return Task.FromResult(execution);
}
public Task<IReadOnlyList<ExportExecution>> ListByJobAsync(string jobId, CancellationToken cancellationToken = default)
{
var executions = _executions.Values
.Where(e => string.Equals(e.JobId, jobId, StringComparison.Ordinal))
.OrderByDescending(e => e.StartedAt)
.ToList();
return Task.FromResult<IReadOnlyList<ExportExecution>>(executions);
}
public Task SaveAsync(ExportExecution execution, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(execution);
_executions[execution.ExecutionId] = execution;
return Task.CompletedTask;
}
}
/// <summary>
/// In-memory implementation of IConsoleExportBundleStore.
/// </summary>
internal sealed class InMemoryConsoleExportBundleStore : IConsoleExportBundleStore
{
private readonly ConcurrentDictionary<string, ExportBundleManifest> _manifests = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, byte[]> _contents = new(StringComparer.Ordinal);
public Task<ExportBundleManifest?> GetAsync(string bundleId, CancellationToken cancellationToken = default)
{
_manifests.TryGetValue(bundleId, out var manifest);
return Task.FromResult(manifest);
}
public Task<IReadOnlyList<ExportBundleManifest>> ListByJobAsync(string jobId, CancellationToken cancellationToken = default)
{
var manifests = _manifests.Values
.Where(m => string.Equals(m.JobId, jobId, StringComparison.Ordinal))
.OrderByDescending(m => m.CreatedAt)
.ToList();
return Task.FromResult<IReadOnlyList<ExportBundleManifest>>(manifests);
}
public Task SaveAsync(ExportBundleManifest manifest, byte[] content, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(manifest);
ArgumentNullException.ThrowIfNull(content);
_manifests[manifest.BundleId] = manifest;
_contents[manifest.BundleId] = content;
return Task.CompletedTask;
}
public Task<byte[]?> GetContentAsync(string bundleId, CancellationToken cancellationToken = default)
{
_contents.TryGetValue(bundleId, out var content);
return Task.FromResult(content);
}
}

View File

@@ -0,0 +1,238 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Policy.Engine.ConsoleExport;
namespace StellaOps.Policy.Engine.Endpoints;
/// <summary>
/// Endpoints for Console export jobs per CONTRACT-EXPORT-BUNDLE-009.
/// </summary>
public static class ConsoleExportEndpoints
{
public static IEndpointRouteBuilder MapConsoleExportJobs(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/v1/export");
// Job management
group.MapPost("/jobs", CreateJobAsync)
.WithName("Export.CreateJob")
.WithDescription("Create a new export job");
group.MapGet("/jobs", ListJobsAsync)
.WithName("Export.ListJobs")
.WithDescription("List export jobs");
group.MapGet("/jobs/{jobId}", GetJobAsync)
.WithName("Export.GetJob")
.WithDescription("Get an export job by ID");
group.MapPut("/jobs/{jobId}", UpdateJobAsync)
.WithName("Export.UpdateJob")
.WithDescription("Update an export job");
group.MapDelete("/jobs/{jobId}", DeleteJobAsync)
.WithName("Export.DeleteJob")
.WithDescription("Delete an export job");
// Job execution
group.MapPost("/jobs/{jobId}/run", TriggerJobAsync)
.WithName("Export.TriggerJob")
.WithDescription("Trigger a job execution");
group.MapGet("/jobs/{jobId}/executions/{executionId}", GetExecutionAsync)
.WithName("Export.GetExecution")
.WithDescription("Get execution status");
// Bundle retrieval
group.MapGet("/bundles/{bundleId}", GetBundleAsync)
.WithName("Export.GetBundle")
.WithDescription("Get bundle manifest");
group.MapGet("/bundles/{bundleId}/download", DownloadBundleAsync)
.WithName("Export.DownloadBundle")
.WithDescription("Download bundle content");
return routes;
}
private static async Task<IResult> CreateJobAsync(
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromBody] CreateExportJobRequest request,
ConsoleExportJobService service,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.Problem(
title: "Tenant ID required",
detail: "X-Tenant-Id header is required",
statusCode: 400,
extensions: new Dictionary<string, object?> { ["code"] = "TENANT_REQUIRED" });
}
try
{
var job = await service.CreateJobAsync(tenantId, request, cancellationToken).ConfigureAwait(false);
return Results.Created($"/api/v1/export/jobs/{job.JobId}", job);
}
catch (ArgumentException ex)
{
var code = ex.Message.Contains("schedule", StringComparison.OrdinalIgnoreCase)
? ExportErrorCodes.InvalidSchedule
: ExportErrorCodes.InvalidDestination;
return Results.Problem(
title: "Validation failed",
detail: ex.Message,
statusCode: 400,
extensions: new Dictionary<string, object?> { ["code"] = code });
}
}
private static async Task<IResult> ListJobsAsync(
[FromQuery] string? tenant_id,
ConsoleExportJobService service,
CancellationToken cancellationToken)
{
var response = await service.ListJobsAsync(tenant_id, cancellationToken).ConfigureAwait(false);
return Results.Ok(response);
}
private static async Task<IResult> GetJobAsync(
[FromRoute] string jobId,
ConsoleExportJobService service,
CancellationToken cancellationToken)
{
var job = await service.GetJobAsync(jobId, cancellationToken).ConfigureAwait(false);
if (job is null)
{
return Results.Problem(
title: "Job not found",
detail: $"Job '{jobId}' not found",
statusCode: 404,
extensions: new Dictionary<string, object?> { ["code"] = ExportErrorCodes.JobNotFound });
}
return Results.Ok(job);
}
private static async Task<IResult> UpdateJobAsync(
[FromRoute] string jobId,
[FromBody] UpdateExportJobRequest request,
ConsoleExportJobService service,
CancellationToken cancellationToken)
{
try
{
var job = await service.UpdateJobAsync(jobId, request, cancellationToken).ConfigureAwait(false);
return Results.Ok(job);
}
catch (KeyNotFoundException)
{
return Results.Problem(
title: "Job not found",
detail: $"Job '{jobId}' not found",
statusCode: 404,
extensions: new Dictionary<string, object?> { ["code"] = ExportErrorCodes.JobNotFound });
}
catch (ArgumentException ex)
{
return Results.Problem(
title: "Validation failed",
detail: ex.Message,
statusCode: 400,
extensions: new Dictionary<string, object?> { ["code"] = ExportErrorCodes.InvalidSchedule });
}
}
private static async Task<IResult> DeleteJobAsync(
[FromRoute] string jobId,
ConsoleExportJobService service,
CancellationToken cancellationToken)
{
await service.DeleteJobAsync(jobId, cancellationToken).ConfigureAwait(false);
return Results.NoContent();
}
private static async Task<IResult> TriggerJobAsync(
[FromRoute] string jobId,
ConsoleExportJobService service,
CancellationToken cancellationToken)
{
try
{
var response = await service.TriggerJobAsync(jobId, cancellationToken).ConfigureAwait(false);
return Results.Accepted($"/api/v1/export/jobs/{jobId}/executions/{response.ExecutionId}", response);
}
catch (KeyNotFoundException)
{
return Results.Problem(
title: "Job not found",
detail: $"Job '{jobId}' not found",
statusCode: 404,
extensions: new Dictionary<string, object?> { ["code"] = ExportErrorCodes.JobNotFound });
}
}
private static async Task<IResult> GetExecutionAsync(
[FromRoute] string jobId,
[FromRoute] string executionId,
ConsoleExportJobService service,
CancellationToken cancellationToken)
{
var execution = await service.GetExecutionAsync(executionId, cancellationToken).ConfigureAwait(false);
if (execution is null || !string.Equals(execution.JobId, jobId, StringComparison.Ordinal))
{
return Results.NotFound();
}
return Results.Ok(execution);
}
private static async Task<IResult> GetBundleAsync(
[FromRoute] string bundleId,
ConsoleExportJobService service,
CancellationToken cancellationToken)
{
var bundle = await service.GetBundleAsync(bundleId, cancellationToken).ConfigureAwait(false);
if (bundle is null)
{
return Results.NotFound();
}
return Results.Ok(bundle);
}
private static async Task<IResult> DownloadBundleAsync(
[FromRoute] string bundleId,
ConsoleExportJobService service,
CancellationToken cancellationToken)
{
var bundle = await service.GetBundleAsync(bundleId, cancellationToken).ConfigureAwait(false);
if (bundle is null)
{
return Results.NotFound();
}
var content = await service.GetBundleContentAsync(bundleId, cancellationToken).ConfigureAwait(false);
if (content is null)
{
return Results.NotFound();
}
var contentType = bundle.Format switch
{
ExportFormats.Ndjson => "application/x-ndjson",
_ => "application/json"
};
var fileName = $"export-{bundle.BundleId}-{DateTime.UtcNow:yyyy-MM-dd}.json";
return Results.File(
content,
contentType,
fileName);
}
}

View File

@@ -0,0 +1,87 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Policy.Engine.AirGap;
namespace StellaOps.Policy.Engine.Endpoints;
/// <summary>
/// Endpoints for policy pack bundle import per CONTRACT-MIRROR-BUNDLE-003.
/// </summary>
public static class PolicyPackBundleEndpoints
{
public static IEndpointRouteBuilder MapPolicyPackBundles(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/v1/airgap/bundles");
group.MapPost("", RegisterBundleAsync)
.WithName("AirGap.RegisterBundle")
.WithDescription("Register a bundle for import");
group.MapGet("{bundleId}", GetBundleStatusAsync)
.WithName("AirGap.GetBundleStatus")
.WithDescription("Get bundle import status");
group.MapGet("", ListBundlesAsync)
.WithName("AirGap.ListBundles")
.WithDescription("List imported bundles");
return routes;
}
private static async Task<IResult> RegisterBundleAsync(
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromBody] RegisterBundleRequest request,
PolicyPackBundleImportService service,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.Problem(
title: "Tenant ID required",
detail: "X-Tenant-Id header is required",
statusCode: 400,
extensions: new Dictionary<string, object?> { ["code"] = "TENANT_REQUIRED" });
}
try
{
var response = await service.RegisterBundleAsync(tenantId, request, cancellationToken).ConfigureAwait(false);
return Results.Accepted($"/api/v1/airgap/bundles/{response.ImportId}", response);
}
catch (ArgumentException ex)
{
return Results.Problem(
title: "Invalid request",
detail: ex.Message,
statusCode: 400,
extensions: new Dictionary<string, object?> { ["code"] = "INVALID_REQUEST" });
}
}
private static async Task<IResult> GetBundleStatusAsync(
[FromRoute] string bundleId,
PolicyPackBundleImportService service,
CancellationToken cancellationToken)
{
var status = await service.GetBundleStatusAsync(bundleId, cancellationToken).ConfigureAwait(false);
if (status is null)
{
return Results.Problem(
title: "Bundle not found",
detail: $"Bundle '{bundleId}' not found",
statusCode: 404,
extensions: new Dictionary<string, object?> { ["code"] = "BUNDLE_NOT_FOUND" });
}
return Results.Ok(status);
}
private static async Task<IResult> ListBundlesAsync(
[FromQuery] string? tenant_id,
PolicyPackBundleImportService service,
CancellationToken cancellationToken)
{
var bundles = await service.ListBundlesAsync(tenant_id, cancellationToken).ConfigureAwait(false);
return Results.Ok(new { items = bundles, total = bundles.Count });
}
}

View File

@@ -166,6 +166,17 @@ builder.Services.AddSingleton<IWorkerResultStore, InMemoryWorkerResultStore>();
builder.Services.AddSingleton<PolicyWorkerService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Ledger.ILedgerExportStore, StellaOps.Policy.Engine.Ledger.InMemoryLedgerExportStore>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Ledger.LedgerExportService>();
// Console export jobs per CONTRACT-EXPORT-BUNDLE-009
builder.Services.AddSingleton<StellaOps.Policy.Engine.ConsoleExport.IConsoleExportJobStore, StellaOps.Policy.Engine.ConsoleExport.InMemoryConsoleExportJobStore>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.ConsoleExport.IConsoleExportExecutionStore, StellaOps.Policy.Engine.ConsoleExport.InMemoryConsoleExportExecutionStore>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.ConsoleExport.IConsoleExportBundleStore, StellaOps.Policy.Engine.ConsoleExport.InMemoryConsoleExportBundleStore>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.ConsoleExport.ConsoleExportJobService>();
// Air-gap bundle import per CONTRACT-MIRROR-BUNDLE-003
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.IPolicyPackBundleStore, StellaOps.Policy.Engine.AirGap.InMemoryPolicyPackBundleStore>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.PolicyPackBundleImportService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Snapshots.ISnapshotStore, StellaOps.Policy.Engine.Snapshots.InMemorySnapshotStore>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Snapshots.SnapshotService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.IViolationEventStore, StellaOps.Policy.Engine.Violations.InMemoryViolationEventStore>();
@@ -279,6 +290,8 @@ app.MapBatchContext();
app.MapOrchestratorJobs();
app.MapPolicyWorker();
app.MapLedgerExport();
app.MapConsoleExportJobs(); // CONTRACT-EXPORT-BUNDLE-009
app.MapPolicyPackBundles(); // CONTRACT-MIRROR-BUNDLE-003
app.MapSnapshots();
app.MapViolations();
app.MapPolicyDecisions();