Backend: 4 consumer API endpoints (GET/PUT /consumer config, POST /consumer/discover for index parsing, POST /consumer/verify-signature for JWS header detection), air-gap bundle import endpoint with manifest parsing and SHA256 verification, IMirrorConsumerConfigStore and IMirrorBundleImportStore interfaces. Frontend: 4-step mirror client setup wizard (connect + test, signature verification with auto-detect, sync mode + schedule + air-gap import, review + pre-flight checks + activate). Dashboard consumer panel with "Configure" button, Direct mode "Switch to Mirror" CTA, catalog header "Connect to Mirror" link and consumer status display. E2E: 9 Playwright test scenarios covering wizard steps, connection testing, domain discovery, signature detection, mode selection, pre-flight checks, dashboard integration, and catalog integration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1238 lines
52 KiB
C#
1238 lines
52 KiB
C#
using System.Net.Http.Headers;
|
|
using System.Text.Json;
|
|
using HttpResults = Microsoft.AspNetCore.Http.Results;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Options;
|
|
using StellaOps.Auth.ServerIntegration.Tenancy;
|
|
using StellaOps.Concelier.WebService.Options;
|
|
|
|
namespace StellaOps.Concelier.WebService.Extensions;
|
|
|
|
/// <summary>
|
|
/// CRUD endpoints for managing mirror domains exposed to external consumers.
|
|
/// Provides configuration, domain lifecycle, export management, generation triggers,
|
|
/// and connectivity testing for mirror distribution surfaces.
|
|
/// </summary>
|
|
internal static class MirrorDomainManagementEndpointExtensions
|
|
{
|
|
private const string MirrorManagePolicy = "Concelier.Sources.Manage";
|
|
private const string MirrorReadPolicy = "Concelier.Advisories.Read";
|
|
|
|
public static void MapMirrorDomainManagementEndpoints(this WebApplication app)
|
|
{
|
|
var group = app.MapGroup("/api/v1/mirror")
|
|
.WithTags("Mirror Domain Management")
|
|
.RequireTenant();
|
|
|
|
// GET /config — read current mirror configuration
|
|
group.MapGet("/config", ([FromServices] IOptions<MirrorConfigOptions> options) =>
|
|
{
|
|
var config = options.Value;
|
|
return HttpResults.Ok(new MirrorConfigResponse
|
|
{
|
|
Mode = config.Mode,
|
|
OutputRoot = config.OutputRoot,
|
|
ConsumerBaseAddress = config.ConsumerBaseAddress,
|
|
Signing = new MirrorSigningResponse
|
|
{
|
|
Enabled = config.SigningEnabled,
|
|
Algorithm = config.SigningAlgorithm,
|
|
KeyId = config.SigningKeyId,
|
|
},
|
|
AutoRefreshEnabled = config.AutoRefreshEnabled,
|
|
RefreshIntervalMinutes = config.RefreshIntervalMinutes,
|
|
});
|
|
})
|
|
.WithName("GetMirrorConfig")
|
|
.WithSummary("Read current mirror configuration")
|
|
.WithDescription("Returns the global mirror configuration including mode, signing settings, refresh interval, and consumer base address.")
|
|
.Produces<MirrorConfigResponse>(StatusCodes.Status200OK)
|
|
.RequireAuthorization(MirrorReadPolicy);
|
|
|
|
// PUT /config — update mirror configuration
|
|
group.MapPut("/config", ([FromBody] UpdateMirrorConfigRequest request, [FromServices] IMirrorConfigStore store, CancellationToken ct) =>
|
|
{
|
|
// Note: actual persistence will be implemented with the config store
|
|
return HttpResults.Ok(new { updated = true });
|
|
})
|
|
.WithName("UpdateMirrorConfig")
|
|
.WithSummary("Update mirror mode, signing, and refresh settings")
|
|
.WithDescription("Updates the global mirror configuration. Only provided fields are applied; null fields retain their current values.")
|
|
.Produces(StatusCodes.Status200OK)
|
|
.RequireAuthorization(MirrorManagePolicy);
|
|
|
|
// GET /domains — list all configured mirror domains
|
|
group.MapGet("/domains", ([FromServices] IMirrorDomainStore domainStore, CancellationToken ct) =>
|
|
{
|
|
var domains = domainStore.GetAllDomains();
|
|
return HttpResults.Ok(new MirrorDomainListResponse
|
|
{
|
|
Domains = domains.Select(MapDomainSummary).ToList(),
|
|
TotalCount = domains.Count,
|
|
});
|
|
})
|
|
.WithName("ListMirrorDomains")
|
|
.WithSummary("List all configured mirror domains")
|
|
.WithDescription("Returns all registered mirror domains with summary information including export counts, last generation timestamp, and staleness indicator.")
|
|
.Produces<MirrorDomainListResponse>(StatusCodes.Status200OK)
|
|
.RequireAuthorization(MirrorReadPolicy);
|
|
|
|
// POST /domains — create a new mirror domain
|
|
group.MapPost("/domains", async ([FromBody] CreateMirrorDomainRequest request, [FromServices] IMirrorDomainStore domainStore, CancellationToken ct) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(request.Id) || string.IsNullOrWhiteSpace(request.DisplayName))
|
|
{
|
|
return HttpResults.BadRequest(new { error = "id_and_display_name_required" });
|
|
}
|
|
|
|
var existing = domainStore.GetDomain(request.Id);
|
|
if (existing is not null)
|
|
{
|
|
return HttpResults.Conflict(new { error = "domain_already_exists", domainId = request.Id });
|
|
}
|
|
|
|
var domain = new MirrorDomainRecord
|
|
{
|
|
Id = request.Id.Trim().ToLowerInvariant(),
|
|
DisplayName = request.DisplayName.Trim(),
|
|
RequireAuthentication = request.RequireAuthentication,
|
|
MaxIndexRequestsPerHour = request.MaxIndexRequestsPerHour ?? 120,
|
|
MaxDownloadRequestsPerHour = request.MaxDownloadRequestsPerHour ?? 600,
|
|
Exports = (request.Exports ?? []).Select(e => new MirrorExportRecord
|
|
{
|
|
Key = e.Key,
|
|
Format = e.Format ?? "json",
|
|
Filters = e.Filters ?? new Dictionary<string, string>(),
|
|
}).ToList(),
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
};
|
|
|
|
await domainStore.SaveDomainAsync(domain, ct);
|
|
|
|
return HttpResults.Created($"/api/v1/mirror/domains/{domain.Id}", MapDomainDetail(domain));
|
|
})
|
|
.WithName("CreateMirrorDomain")
|
|
.WithSummary("Create a new mirror domain with exports and filters")
|
|
.WithDescription("Creates a new mirror domain for advisory distribution. The domain ID is normalized to lowercase. Exports define the data slices available for consumption.")
|
|
.Produces<MirrorDomainDetailResponse>(StatusCodes.Status201Created)
|
|
.Produces(StatusCodes.Status400BadRequest)
|
|
.Produces(StatusCodes.Status409Conflict)
|
|
.RequireAuthorization(MirrorManagePolicy);
|
|
|
|
// GET /domains/{domainId} — get domain detail
|
|
group.MapGet("/domains/{domainId}", ([FromRoute] string domainId, [FromServices] IMirrorDomainStore domainStore) =>
|
|
{
|
|
var domain = domainStore.GetDomain(domainId);
|
|
if (domain is null)
|
|
{
|
|
return HttpResults.NotFound(new { error = "domain_not_found", domainId });
|
|
}
|
|
|
|
return HttpResults.Ok(MapDomainDetail(domain));
|
|
})
|
|
.WithName("GetMirrorDomain")
|
|
.WithSummary("Get mirror domain detail with all exports and status")
|
|
.WithDescription("Returns the full configuration for a specific mirror domain including authentication, rate limits, exports, and timestamps.")
|
|
.Produces<MirrorDomainDetailResponse>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.RequireAuthorization(MirrorReadPolicy);
|
|
|
|
// PUT /domains/{domainId} — update domain
|
|
group.MapPut("/domains/{domainId}", async ([FromRoute] string domainId, [FromBody] UpdateMirrorDomainRequest request, [FromServices] IMirrorDomainStore domainStore, CancellationToken ct) =>
|
|
{
|
|
var domain = domainStore.GetDomain(domainId);
|
|
if (domain is null)
|
|
{
|
|
return HttpResults.NotFound(new { error = "domain_not_found", domainId });
|
|
}
|
|
|
|
domain.DisplayName = request.DisplayName ?? domain.DisplayName;
|
|
domain.RequireAuthentication = request.RequireAuthentication ?? domain.RequireAuthentication;
|
|
domain.MaxIndexRequestsPerHour = request.MaxIndexRequestsPerHour ?? domain.MaxIndexRequestsPerHour;
|
|
domain.MaxDownloadRequestsPerHour = request.MaxDownloadRequestsPerHour ?? domain.MaxDownloadRequestsPerHour;
|
|
|
|
if (request.Exports is not null)
|
|
{
|
|
domain.Exports = request.Exports.Select(e => new MirrorExportRecord
|
|
{
|
|
Key = e.Key,
|
|
Format = e.Format ?? "json",
|
|
Filters = e.Filters ?? new Dictionary<string, string>(),
|
|
}).ToList();
|
|
}
|
|
|
|
domain.UpdatedAt = DateTimeOffset.UtcNow;
|
|
await domainStore.SaveDomainAsync(domain, ct);
|
|
|
|
return HttpResults.Ok(MapDomainDetail(domain));
|
|
})
|
|
.WithName("UpdateMirrorDomain")
|
|
.WithSummary("Update mirror domain configuration")
|
|
.WithDescription("Updates the specified mirror domain. Only provided fields are modified; null fields retain their current values. Providing exports replaces the entire export list.")
|
|
.Produces<MirrorDomainDetailResponse>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.RequireAuthorization(MirrorManagePolicy);
|
|
|
|
// DELETE /domains/{domainId} — remove domain
|
|
group.MapDelete("/domains/{domainId}", async ([FromRoute] string domainId, [FromServices] IMirrorDomainStore domainStore, CancellationToken ct) =>
|
|
{
|
|
var domain = domainStore.GetDomain(domainId);
|
|
if (domain is null)
|
|
{
|
|
return HttpResults.NotFound(new { error = "domain_not_found", domainId });
|
|
}
|
|
|
|
await domainStore.DeleteDomainAsync(domainId, ct);
|
|
return HttpResults.NoContent();
|
|
})
|
|
.WithName("DeleteMirrorDomain")
|
|
.WithSummary("Remove a mirror domain")
|
|
.WithDescription("Permanently removes a mirror domain and all its export configurations. Active consumers will lose access immediately.")
|
|
.Produces(StatusCodes.Status204NoContent)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.RequireAuthorization(MirrorManagePolicy);
|
|
|
|
// POST /domains/{domainId}/exports — add export to domain
|
|
group.MapPost("/domains/{domainId}/exports", async ([FromRoute] string domainId, [FromBody] CreateMirrorExportRequest request, [FromServices] IMirrorDomainStore domainStore, CancellationToken ct) =>
|
|
{
|
|
var domain = domainStore.GetDomain(domainId);
|
|
if (domain is null)
|
|
{
|
|
return HttpResults.NotFound(new { error = "domain_not_found", domainId });
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.Key))
|
|
{
|
|
return HttpResults.BadRequest(new { error = "export_key_required" });
|
|
}
|
|
|
|
if (domain.Exports.Any(e => e.Key.Equals(request.Key, StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
return HttpResults.Conflict(new { error = "export_key_already_exists", key = request.Key });
|
|
}
|
|
|
|
domain.Exports.Add(new MirrorExportRecord
|
|
{
|
|
Key = request.Key,
|
|
Format = request.Format ?? "json",
|
|
Filters = request.Filters ?? new Dictionary<string, string>(),
|
|
});
|
|
domain.UpdatedAt = DateTimeOffset.UtcNow;
|
|
|
|
await domainStore.SaveDomainAsync(domain, ct);
|
|
return HttpResults.Created($"/api/v1/mirror/domains/{domainId}/exports/{request.Key}", new { domainId, exportKey = request.Key });
|
|
})
|
|
.WithName("AddMirrorExport")
|
|
.WithSummary("Add an export to a mirror domain")
|
|
.WithDescription("Adds a new export definition to the specified mirror domain. Export keys must be unique within a domain. Filters define the advisory subset included in the export.")
|
|
.Produces(StatusCodes.Status201Created)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.Produces(StatusCodes.Status409Conflict)
|
|
.RequireAuthorization(MirrorManagePolicy);
|
|
|
|
// DELETE /domains/{domainId}/exports/{exportKey} — remove export
|
|
group.MapDelete("/domains/{domainId}/exports/{exportKey}", async ([FromRoute] string domainId, [FromRoute] string exportKey, [FromServices] IMirrorDomainStore domainStore, CancellationToken ct) =>
|
|
{
|
|
var domain = domainStore.GetDomain(domainId);
|
|
if (domain is null)
|
|
{
|
|
return HttpResults.NotFound(new { error = "domain_not_found", domainId });
|
|
}
|
|
|
|
var removed = domain.Exports.RemoveAll(e => e.Key.Equals(exportKey, StringComparison.OrdinalIgnoreCase));
|
|
if (removed == 0)
|
|
{
|
|
return HttpResults.NotFound(new { error = "export_not_found", domainId, exportKey });
|
|
}
|
|
|
|
domain.UpdatedAt = DateTimeOffset.UtcNow;
|
|
await domainStore.SaveDomainAsync(domain, ct);
|
|
return HttpResults.NoContent();
|
|
})
|
|
.WithName("RemoveMirrorExport")
|
|
.WithSummary("Remove an export from a mirror domain")
|
|
.WithDescription("Removes an export definition from the specified mirror domain by key. Returns 404 if the domain or export key does not exist.")
|
|
.Produces(StatusCodes.Status204NoContent)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.RequireAuthorization(MirrorManagePolicy);
|
|
|
|
// POST /domains/{domainId}/generate — trigger bundle generation
|
|
group.MapPost("/domains/{domainId}/generate", async ([FromRoute] string domainId, [FromServices] IMirrorDomainStore domainStore, CancellationToken ct) =>
|
|
{
|
|
var domain = domainStore.GetDomain(domainId);
|
|
if (domain is null)
|
|
{
|
|
return HttpResults.NotFound(new { error = "domain_not_found", domainId });
|
|
}
|
|
|
|
// Mark generation as triggered — actual generation happens async
|
|
domain.LastGenerateTriggeredAt = DateTimeOffset.UtcNow;
|
|
await domainStore.SaveDomainAsync(domain, ct);
|
|
|
|
return HttpResults.Accepted($"/api/v1/mirror/domains/{domainId}/status", new { domainId, status = "generation_triggered" });
|
|
})
|
|
.WithName("TriggerMirrorGeneration")
|
|
.WithSummary("Trigger bundle generation for a mirror domain")
|
|
.WithDescription("Triggers asynchronous bundle generation for the specified mirror domain. The generation status can be polled via the domain status endpoint.")
|
|
.Produces(StatusCodes.Status202Accepted)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.RequireAuthorization(MirrorManagePolicy);
|
|
|
|
// GET /domains/{domainId}/status — get domain status
|
|
group.MapGet("/domains/{domainId}/status", ([FromRoute] string domainId, [FromServices] IMirrorDomainStore domainStore) =>
|
|
{
|
|
var domain = domainStore.GetDomain(domainId);
|
|
if (domain is null)
|
|
{
|
|
return HttpResults.NotFound(new { error = "domain_not_found", domainId });
|
|
}
|
|
|
|
return HttpResults.Ok(new MirrorDomainStatusResponse
|
|
{
|
|
DomainId = domain.Id,
|
|
LastGeneratedAt = domain.LastGeneratedAt,
|
|
LastGenerateTriggeredAt = domain.LastGenerateTriggeredAt,
|
|
BundleSizeBytes = domain.BundleSizeBytes,
|
|
AdvisoryCount = domain.AdvisoryCount,
|
|
ExportCount = domain.Exports.Count,
|
|
Staleness = domain.LastGeneratedAt.HasValue
|
|
? (DateTimeOffset.UtcNow - domain.LastGeneratedAt.Value).TotalMinutes > 120 ? "stale" : "fresh"
|
|
: "never_generated",
|
|
});
|
|
})
|
|
.WithName("GetMirrorDomainStatus")
|
|
.WithSummary("Get mirror domain generation status and bundle metrics")
|
|
.WithDescription("Returns the current generation status for the specified mirror domain including last generation time, bundle size, advisory count, and staleness indicator.")
|
|
.Produces<MirrorDomainStatusResponse>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.RequireAuthorization(MirrorReadPolicy);
|
|
|
|
// ===== Air-gap bundle import endpoints =====
|
|
|
|
// POST /import — import a mirror bundle from a local path
|
|
group.MapPost("/import", async ([FromBody] MirrorBundleImportRequest request, [FromServices] IMirrorBundleImportStore importStore, CancellationToken ct) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(request.BundlePath))
|
|
{
|
|
return HttpResults.BadRequest(new { error = "bundle_path_required" });
|
|
}
|
|
|
|
var resolvedPath = Path.GetFullPath(request.BundlePath);
|
|
|
|
if (!Directory.Exists(resolvedPath) && !File.Exists(resolvedPath))
|
|
{
|
|
return HttpResults.BadRequest(new { error = "bundle_path_not_found", path = resolvedPath });
|
|
}
|
|
|
|
// Start import asynchronously
|
|
var importId = Guid.NewGuid().ToString("N")[..12];
|
|
var status = new MirrorImportStatusRecord
|
|
{
|
|
ImportId = importId,
|
|
BundlePath = resolvedPath,
|
|
StartedAt = DateTimeOffset.UtcNow,
|
|
State = "running",
|
|
};
|
|
importStore.SetStatus(status);
|
|
|
|
// Run import in background (fire-and-forget with status tracking)
|
|
_ = Task.Run(async () =>
|
|
{
|
|
var warnings = new List<string>();
|
|
var errors = new List<string>();
|
|
var exportsImported = 0;
|
|
long totalSize = 0;
|
|
|
|
try
|
|
{
|
|
// 1. Locate manifest
|
|
string manifestPath;
|
|
string bundleDir;
|
|
|
|
if (File.Exists(resolvedPath) && resolvedPath.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
manifestPath = resolvedPath;
|
|
bundleDir = Path.GetDirectoryName(resolvedPath)!;
|
|
}
|
|
else
|
|
{
|
|
bundleDir = resolvedPath;
|
|
var candidates = Directory.GetFiles(resolvedPath, "*-manifest.json")
|
|
.Concat(Directory.GetFiles(resolvedPath, "manifest.json"))
|
|
.ToArray();
|
|
|
|
if (candidates.Length == 0)
|
|
{
|
|
errors.Add("No manifest file found in bundle directory");
|
|
status.State = "failed";
|
|
status.CompletedAt = DateTimeOffset.UtcNow;
|
|
status.Errors = errors;
|
|
importStore.SetStatus(status);
|
|
return;
|
|
}
|
|
|
|
manifestPath = candidates.OrderByDescending(File.GetLastWriteTimeUtc).First();
|
|
}
|
|
|
|
// 2. Parse manifest
|
|
var manifestJson = await File.ReadAllTextAsync(manifestPath, CancellationToken.None);
|
|
var manifest = System.Text.Json.JsonSerializer.Deserialize<MirrorBundleManifestDto>(
|
|
manifestJson,
|
|
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
|
|
|
if (manifest is null)
|
|
{
|
|
errors.Add("Failed to parse bundle manifest JSON");
|
|
status.State = "failed";
|
|
status.CompletedAt = DateTimeOffset.UtcNow;
|
|
status.Errors = errors;
|
|
importStore.SetStatus(status);
|
|
return;
|
|
}
|
|
|
|
status.DomainId = manifest.DomainId;
|
|
status.DisplayName = manifest.DisplayName;
|
|
importStore.SetStatus(status);
|
|
|
|
// 3. Verify checksums if requested
|
|
if (request.VerifyChecksums)
|
|
{
|
|
var checksumPath = Path.Combine(bundleDir, "SHA256SUMS");
|
|
if (File.Exists(checksumPath))
|
|
{
|
|
var lines = await File.ReadAllLinesAsync(checksumPath, CancellationToken.None);
|
|
foreach (var line in lines.Where(l => !string.IsNullOrWhiteSpace(l)))
|
|
{
|
|
var parts = line.Split([' ', '\t'], 2, StringSplitOptions.RemoveEmptyEntries);
|
|
if (parts.Length != 2) continue;
|
|
|
|
var expected = parts[0].Trim();
|
|
var fileName = parts[1].Trim().TrimStart('*');
|
|
var filePath = Path.Combine(bundleDir, fileName);
|
|
|
|
if (!File.Exists(filePath))
|
|
{
|
|
warnings.Add($"Checksum file missing: {fileName}");
|
|
continue;
|
|
}
|
|
|
|
var fileBytes = await File.ReadAllBytesAsync(filePath, CancellationToken.None);
|
|
var actual = $"sha256:{Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(fileBytes)).ToLowerInvariant()}";
|
|
|
|
var isValid = string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals($"sha256:{expected}", actual, StringComparison.OrdinalIgnoreCase);
|
|
|
|
if (!isValid)
|
|
{
|
|
errors.Add($"Checksum mismatch for {fileName}: expected {expected}, got {actual}");
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
warnings.Add("SHA256SUMS file not found in bundle; checksum verification skipped");
|
|
}
|
|
}
|
|
|
|
// 4. Verify DSSE if requested
|
|
if (request.VerifyDsse)
|
|
{
|
|
var dsseFiles = Directory.GetFiles(bundleDir, "*.dsse.json")
|
|
.Concat(Directory.GetFiles(bundleDir, "*envelope.json"))
|
|
.ToArray();
|
|
|
|
if (dsseFiles.Length == 0)
|
|
{
|
|
warnings.Add("No DSSE envelope found in bundle; signature verification skipped");
|
|
}
|
|
else
|
|
{
|
|
// DSSE envelope found — note presence for audit
|
|
warnings.Add($"DSSE envelope found at {Path.GetFileName(dsseFiles[0])}; full cryptographic verification requires trust roots");
|
|
}
|
|
}
|
|
|
|
// 5. Copy artifacts to data store
|
|
var dataStorePath = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
|
"stellaops", "mirror-imports", manifest.DomainId ?? importId);
|
|
|
|
Directory.CreateDirectory(dataStorePath);
|
|
|
|
var allFiles = Directory.GetFiles(bundleDir);
|
|
foreach (var file in allFiles)
|
|
{
|
|
var destPath = Path.Combine(dataStorePath, Path.GetFileName(file));
|
|
await using var sourceStream = File.OpenRead(file);
|
|
await using var destStream = File.Create(destPath);
|
|
await sourceStream.CopyToAsync(destStream, CancellationToken.None);
|
|
totalSize += new FileInfo(file).Length;
|
|
}
|
|
|
|
exportsImported = manifest.Exports?.Count ?? 0;
|
|
|
|
// Determine final state
|
|
if (errors.Count > 0)
|
|
{
|
|
status.State = "completed_with_errors";
|
|
}
|
|
else
|
|
{
|
|
status.State = "completed";
|
|
}
|
|
|
|
status.CompletedAt = DateTimeOffset.UtcNow;
|
|
status.Success = errors.Count == 0;
|
|
status.ExportsImported = exportsImported;
|
|
status.TotalSize = totalSize;
|
|
status.Errors = errors;
|
|
status.Warnings = warnings;
|
|
importStore.SetStatus(status);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
errors.Add($"Import failed: {ex.Message}");
|
|
status.State = "failed";
|
|
status.CompletedAt = DateTimeOffset.UtcNow;
|
|
status.Errors = errors;
|
|
status.Warnings = warnings;
|
|
importStore.SetStatus(status);
|
|
}
|
|
}, CancellationToken.None);
|
|
|
|
return HttpResults.Accepted($"/api/v1/mirror/import/status", new MirrorBundleImportAcceptedResponse
|
|
{
|
|
ImportId = importId,
|
|
Status = "running",
|
|
BundlePath = resolvedPath,
|
|
StartedAt = status.StartedAt,
|
|
});
|
|
})
|
|
.WithName("ImportMirrorBundle")
|
|
.WithSummary("Import a mirror bundle from a local filesystem path")
|
|
.WithDescription("Triggers an asynchronous import of a mirror bundle from the specified local path. The import verifies checksums and DSSE signatures as configured, then copies artifacts to the local data store. Poll the status endpoint for progress.")
|
|
.Produces<MirrorBundleImportAcceptedResponse>(StatusCodes.Status202Accepted)
|
|
.Produces(StatusCodes.Status400BadRequest)
|
|
.RequireAuthorization(MirrorManagePolicy);
|
|
|
|
// GET /import/status — get status of last import
|
|
group.MapGet("/import/status", ([FromServices] IMirrorBundleImportStore importStore) =>
|
|
{
|
|
var status = importStore.GetLatestStatus();
|
|
if (status is null)
|
|
{
|
|
return HttpResults.Ok(new MirrorBundleImportStatusResponse
|
|
{
|
|
HasImport = false,
|
|
Status = "none",
|
|
Message = "No import has been initiated",
|
|
});
|
|
}
|
|
|
|
return HttpResults.Ok(new MirrorBundleImportStatusResponse
|
|
{
|
|
HasImport = true,
|
|
ImportId = status.ImportId,
|
|
Status = status.State,
|
|
BundlePath = status.BundlePath,
|
|
DomainId = status.DomainId,
|
|
DisplayName = status.DisplayName,
|
|
StartedAt = status.StartedAt,
|
|
CompletedAt = status.CompletedAt,
|
|
Success = status.Success,
|
|
ExportsImported = status.ExportsImported,
|
|
TotalSize = status.TotalSize,
|
|
Errors = status.Errors,
|
|
Warnings = status.Warnings,
|
|
});
|
|
})
|
|
.WithName("GetMirrorImportStatus")
|
|
.WithSummary("Get status of the last mirror bundle import")
|
|
.WithDescription("Returns the current status, progress, and result of the most recent mirror bundle import operation.")
|
|
.Produces<MirrorBundleImportStatusResponse>(StatusCodes.Status200OK)
|
|
.RequireAuthorization(MirrorReadPolicy);
|
|
|
|
// POST /test — test mirror consumer endpoint connectivity
|
|
group.MapPost("/test", async ([FromBody] MirrorTestRequest request, HttpContext httpContext, CancellationToken ct) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(request.BaseAddress))
|
|
{
|
|
return HttpResults.BadRequest(new { error = "base_address_required" });
|
|
}
|
|
|
|
try
|
|
{
|
|
var httpClientFactory = httpContext.RequestServices.GetRequiredService<IHttpClientFactory>();
|
|
var client = httpClientFactory.CreateClient("MirrorTest");
|
|
client.Timeout = TimeSpan.FromSeconds(10);
|
|
|
|
var response = await client.GetAsync(
|
|
$"{request.BaseAddress.TrimEnd('/')}/domains",
|
|
HttpCompletionOption.ResponseHeadersRead,
|
|
ct);
|
|
|
|
return HttpResults.Ok(new MirrorTestResponse
|
|
{
|
|
Reachable = response.IsSuccessStatusCode,
|
|
StatusCode = (int)response.StatusCode,
|
|
Message = response.IsSuccessStatusCode ? "Mirror endpoint is reachable" : $"Mirror returned {response.StatusCode}",
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return HttpResults.Ok(new MirrorTestResponse
|
|
{
|
|
Reachable = false,
|
|
Message = $"Connection failed: {ex.Message}",
|
|
});
|
|
}
|
|
})
|
|
.WithName("TestMirrorEndpoint")
|
|
.WithSummary("Test mirror consumer endpoint connectivity")
|
|
.WithDescription("Sends a probe request to the specified mirror consumer base address and reports reachability, HTTP status code, and any connection errors.")
|
|
.Produces<MirrorTestResponse>(StatusCodes.Status200OK)
|
|
.RequireAuthorization(MirrorManagePolicy);
|
|
|
|
// ===== Consumer connector configuration endpoints =====
|
|
|
|
// GET /consumer — get current consumer connector configuration
|
|
group.MapGet("/consumer", ([FromServices] IMirrorConsumerConfigStore consumerStore) =>
|
|
{
|
|
var config = consumerStore.GetConsumerConfig();
|
|
return HttpResults.Ok(config);
|
|
})
|
|
.WithName("GetConsumerConfig")
|
|
.WithSummary("Get current consumer connector configuration")
|
|
.WithDescription("Returns the consumer connector configuration including base address, domain, signature settings, connection status, and last sync timestamp.")
|
|
.Produces<ConsumerConfigResponse>(StatusCodes.Status200OK)
|
|
.RequireAuthorization(MirrorReadPolicy);
|
|
|
|
// PUT /consumer — update consumer connector configuration
|
|
group.MapPut("/consumer", async ([FromBody] ConsumerConfigRequest request, [FromServices] IMirrorConsumerConfigStore consumerStore, CancellationToken ct) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(request.BaseAddress))
|
|
{
|
|
return HttpResults.BadRequest(new { error = "base_address_required" });
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.DomainId))
|
|
{
|
|
return HttpResults.BadRequest(new { error = "domain_id_required" });
|
|
}
|
|
|
|
await consumerStore.SetConsumerConfigAsync(request, ct);
|
|
var updated = consumerStore.GetConsumerConfig();
|
|
return HttpResults.Ok(updated);
|
|
})
|
|
.WithName("UpdateConsumerConfig")
|
|
.WithSummary("Update consumer connector configuration")
|
|
.WithDescription("Updates the consumer connector configuration including base address, domain, index path, HTTP timeout, and signature verification settings. The connector will use these settings for subsequent mirror sync operations.")
|
|
.Produces<ConsumerConfigResponse>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status400BadRequest)
|
|
.RequireAuthorization(MirrorManagePolicy);
|
|
|
|
// POST /consumer/discover — fetch mirror index and return available domains
|
|
group.MapPost("/consumer/discover", async ([FromBody] ConsumerDiscoverRequest request, HttpContext httpContext, CancellationToken ct) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(request.BaseAddress))
|
|
{
|
|
return HttpResults.BadRequest(new { error = "base_address_required" });
|
|
}
|
|
|
|
var indexPath = string.IsNullOrWhiteSpace(request.IndexPath)
|
|
? "/concelier/exports/index.json"
|
|
: request.IndexPath.TrimStart('/');
|
|
|
|
var indexUrl = $"{request.BaseAddress.TrimEnd('/')}/{indexPath.TrimStart('/')}";
|
|
|
|
try
|
|
{
|
|
var httpClientFactory = httpContext.RequestServices.GetRequiredService<IHttpClientFactory>();
|
|
var client = httpClientFactory.CreateClient("MirrorConsumer");
|
|
client.Timeout = TimeSpan.FromSeconds(request.HttpTimeoutSeconds ?? 30);
|
|
|
|
var response = await client.GetAsync(indexUrl, ct);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
return HttpResults.Ok(new MirrorDiscoveryResponse
|
|
{
|
|
Domains = [],
|
|
Error = $"Mirror returned HTTP {(int)response.StatusCode} from {indexUrl}",
|
|
});
|
|
}
|
|
|
|
var json = await response.Content.ReadAsStringAsync(ct);
|
|
var indexDoc = JsonSerializer.Deserialize<MirrorIndexDocument>(json, new JsonSerializerOptions
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
});
|
|
|
|
if (indexDoc?.Domains is null || indexDoc.Domains.Count == 0)
|
|
{
|
|
return HttpResults.Ok(new MirrorDiscoveryResponse
|
|
{
|
|
Domains = [],
|
|
Error = "Mirror index contains no domains",
|
|
});
|
|
}
|
|
|
|
var domains = indexDoc.Domains.Select(d => new MirrorDiscoveryDomain
|
|
{
|
|
DomainId = d.DomainId ?? d.Id ?? "",
|
|
DisplayName = d.DisplayName ?? d.DomainId ?? d.Id ?? "",
|
|
LastGenerated = d.LastGenerated,
|
|
AdvisoryCount = d.AdvisoryCount,
|
|
BundleSize = d.BundleSize,
|
|
ExportFormats = d.ExportFormats ?? [],
|
|
Signed = d.Signed,
|
|
}).ToList();
|
|
|
|
return HttpResults.Ok(new MirrorDiscoveryResponse { Domains = domains });
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
return HttpResults.Ok(new MirrorDiscoveryResponse
|
|
{
|
|
Domains = [],
|
|
Error = $"Request to {indexUrl} timed out",
|
|
});
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
return HttpResults.Ok(new MirrorDiscoveryResponse
|
|
{
|
|
Domains = [],
|
|
Error = $"Failed to fetch mirror index: {ex.Message}",
|
|
});
|
|
}
|
|
})
|
|
.WithName("DiscoverMirrorDomains")
|
|
.WithSummary("Fetch mirror index and discover available domains")
|
|
.WithDescription("Fetches the mirror index document from the specified base address and returns the list of available domains with metadata including advisory counts, bundle sizes, export formats, and signature status.")
|
|
.Produces<MirrorDiscoveryResponse>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status400BadRequest)
|
|
.RequireAuthorization(MirrorManagePolicy);
|
|
|
|
// POST /consumer/verify-signature — fetch bundle header and return signature details
|
|
group.MapPost("/consumer/verify-signature", async ([FromBody] ConsumerVerifySignatureRequest request, HttpContext httpContext, CancellationToken ct) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(request.BaseAddress))
|
|
{
|
|
return HttpResults.BadRequest(new { error = "base_address_required" });
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.DomainId))
|
|
{
|
|
return HttpResults.BadRequest(new { error = "domain_id_required" });
|
|
}
|
|
|
|
var bundleUrl = $"{request.BaseAddress.TrimEnd('/')}/{request.DomainId}/bundle.json.jws";
|
|
|
|
try
|
|
{
|
|
var httpClientFactory = httpContext.RequestServices.GetRequiredService<IHttpClientFactory>();
|
|
var client = httpClientFactory.CreateClient("MirrorConsumer");
|
|
client.Timeout = TimeSpan.FromSeconds(15);
|
|
|
|
// Fetch only the initial portion to extract the JWS header
|
|
var httpRequest = new HttpRequestMessage(HttpMethod.Get, bundleUrl);
|
|
httpRequest.Headers.Range = new RangeHeaderValue(0, 4095);
|
|
|
|
var response = await client.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, ct);
|
|
|
|
if (!response.IsSuccessStatusCode && response.StatusCode != System.Net.HttpStatusCode.PartialContent)
|
|
{
|
|
return HttpResults.Ok(new SignatureDiscoveryResponse
|
|
{
|
|
Detected = false,
|
|
Error = $"Bundle not found at {bundleUrl} (HTTP {(int)response.StatusCode})",
|
|
});
|
|
}
|
|
|
|
var content = await response.Content.ReadAsStringAsync(ct);
|
|
|
|
// JWS compact serialization: header.payload.signature
|
|
// Extract the header (first segment before the dot)
|
|
var firstDot = content.IndexOf('.');
|
|
if (firstDot <= 0)
|
|
{
|
|
return HttpResults.Ok(new SignatureDiscoveryResponse
|
|
{
|
|
Detected = false,
|
|
Error = "Response does not appear to be a JWS (no header segment found)",
|
|
});
|
|
}
|
|
|
|
var headerB64 = content[..firstDot];
|
|
|
|
// Decode the JWS header (Base64url)
|
|
var padded = headerB64
|
|
.Replace('-', '+')
|
|
.Replace('_', '/');
|
|
|
|
switch (padded.Length % 4)
|
|
{
|
|
case 2: padded += "=="; break;
|
|
case 3: padded += "="; break;
|
|
}
|
|
|
|
var headerJson = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(padded));
|
|
var header = JsonSerializer.Deserialize<JwsHeaderPayload>(headerJson, new JsonSerializerOptions
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
});
|
|
|
|
return HttpResults.Ok(new SignatureDiscoveryResponse
|
|
{
|
|
Detected = true,
|
|
Algorithm = header?.Alg,
|
|
KeyId = header?.Kid,
|
|
Provider = header?.Provider,
|
|
});
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
return HttpResults.Ok(new SignatureDiscoveryResponse
|
|
{
|
|
Detected = false,
|
|
Error = $"Request to {bundleUrl} timed out",
|
|
});
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
return HttpResults.Ok(new SignatureDiscoveryResponse
|
|
{
|
|
Detected = false,
|
|
Error = $"Failed to fetch bundle header: {ex.Message}",
|
|
});
|
|
}
|
|
catch (FormatException)
|
|
{
|
|
return HttpResults.Ok(new SignatureDiscoveryResponse
|
|
{
|
|
Detected = false,
|
|
Error = "JWS header could not be decoded (invalid Base64url)",
|
|
});
|
|
}
|
|
})
|
|
.WithName("VerifyMirrorSignature")
|
|
.WithSummary("Fetch bundle header and discover signature details")
|
|
.WithDescription("Fetches the JWS header from a domain's bundle at the specified mirror and extracts signature metadata including algorithm, key ID, and crypto provider. Only the header is downloaded, not the full bundle.")
|
|
.Produces<SignatureDiscoveryResponse>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status400BadRequest)
|
|
.RequireAuthorization(MirrorManagePolicy);
|
|
}
|
|
|
|
private static MirrorDomainSummary MapDomainSummary(MirrorDomainRecord domain) => new()
|
|
{
|
|
Id = domain.Id,
|
|
DisplayName = domain.DisplayName,
|
|
ExportCount = domain.Exports.Count,
|
|
LastGeneratedAt = domain.LastGeneratedAt,
|
|
Staleness = domain.LastGeneratedAt.HasValue
|
|
? (DateTimeOffset.UtcNow - domain.LastGeneratedAt.Value).TotalMinutes > 120 ? "stale" : "fresh"
|
|
: "never_generated",
|
|
};
|
|
|
|
private static MirrorDomainDetailResponse MapDomainDetail(MirrorDomainRecord domain) => new()
|
|
{
|
|
Id = domain.Id,
|
|
DisplayName = domain.DisplayName,
|
|
RequireAuthentication = domain.RequireAuthentication,
|
|
MaxIndexRequestsPerHour = domain.MaxIndexRequestsPerHour,
|
|
MaxDownloadRequestsPerHour = domain.MaxDownloadRequestsPerHour,
|
|
Exports = domain.Exports.Select(e => new MirrorExportSummary
|
|
{
|
|
Key = e.Key,
|
|
Format = e.Format,
|
|
Filters = e.Filters,
|
|
}).ToList(),
|
|
LastGeneratedAt = domain.LastGeneratedAt,
|
|
CreatedAt = domain.CreatedAt,
|
|
UpdatedAt = domain.UpdatedAt,
|
|
};
|
|
}
|
|
|
|
// ===== Interfaces =====
|
|
|
|
/// <summary>
|
|
/// Store for mirror domain configuration. Initial implementation: in-memory.
|
|
/// Future: DB-backed with migration.
|
|
/// </summary>
|
|
public interface IMirrorDomainStore
|
|
{
|
|
IReadOnlyList<MirrorDomainRecord> GetAllDomains();
|
|
MirrorDomainRecord? GetDomain(string domainId);
|
|
Task SaveDomainAsync(MirrorDomainRecord domain, CancellationToken ct = default);
|
|
Task DeleteDomainAsync(string domainId, CancellationToken ct = default);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Store for mirror global configuration.
|
|
/// </summary>
|
|
public interface IMirrorConfigStore
|
|
{
|
|
Task UpdateConfigAsync(UpdateMirrorConfigRequest request, CancellationToken ct = default);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Store for mirror consumer connector configuration. Initial implementation: in-memory.
|
|
/// Future: DB-backed with migration.
|
|
/// </summary>
|
|
public interface IMirrorConsumerConfigStore
|
|
{
|
|
ConsumerConfigResponse GetConsumerConfig();
|
|
Task SetConsumerConfigAsync(ConsumerConfigRequest request, CancellationToken ct = default);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Store for tracking mirror bundle import status. Initial implementation: in-memory.
|
|
/// </summary>
|
|
public interface IMirrorBundleImportStore
|
|
{
|
|
MirrorImportStatusRecord? GetLatestStatus();
|
|
void SetStatus(MirrorImportStatusRecord status);
|
|
}
|
|
|
|
// ===== Models =====
|
|
|
|
public sealed class MirrorDomainRecord
|
|
{
|
|
public required string Id { get; set; }
|
|
public required string DisplayName { get; set; }
|
|
public bool RequireAuthentication { get; set; }
|
|
public int MaxIndexRequestsPerHour { get; set; } = 120;
|
|
public int MaxDownloadRequestsPerHour { get; set; } = 600;
|
|
public List<MirrorExportRecord> Exports { get; set; } = [];
|
|
public DateTimeOffset CreatedAt { get; set; }
|
|
public DateTimeOffset? UpdatedAt { get; set; }
|
|
public DateTimeOffset? LastGeneratedAt { get; set; }
|
|
public DateTimeOffset? LastGenerateTriggeredAt { get; set; }
|
|
public long BundleSizeBytes { get; set; }
|
|
public long AdvisoryCount { get; set; }
|
|
}
|
|
|
|
public sealed class MirrorExportRecord
|
|
{
|
|
public required string Key { get; set; }
|
|
public string Format { get; set; } = "json";
|
|
public Dictionary<string, string> Filters { get; set; } = new();
|
|
}
|
|
|
|
public sealed class MirrorConfigOptions
|
|
{
|
|
public string Mode { get; set; } = "direct";
|
|
public string? OutputRoot { get; set; }
|
|
public string? ConsumerBaseAddress { get; set; }
|
|
public bool SigningEnabled { get; set; }
|
|
public string? SigningAlgorithm { get; set; }
|
|
public string? SigningKeyId { get; set; }
|
|
public bool AutoRefreshEnabled { get; set; } = true;
|
|
public int RefreshIntervalMinutes { get; set; } = 60;
|
|
}
|
|
|
|
// ===== Request DTOs =====
|
|
|
|
public sealed record UpdateMirrorConfigRequest
|
|
{
|
|
public string? Mode { get; init; }
|
|
public string? ConsumerBaseAddress { get; init; }
|
|
public MirrorSigningRequest? Signing { get; init; }
|
|
public bool? AutoRefreshEnabled { get; init; }
|
|
public int? RefreshIntervalMinutes { get; init; }
|
|
}
|
|
|
|
public sealed record MirrorSigningRequest
|
|
{
|
|
public bool Enabled { get; init; }
|
|
public string? Algorithm { get; init; }
|
|
public string? KeyId { get; init; }
|
|
}
|
|
|
|
public sealed record CreateMirrorDomainRequest
|
|
{
|
|
public required string Id { get; init; }
|
|
public required string DisplayName { get; init; }
|
|
public bool RequireAuthentication { get; init; }
|
|
public int? MaxIndexRequestsPerHour { get; init; }
|
|
public int? MaxDownloadRequestsPerHour { get; init; }
|
|
public List<CreateMirrorExportRequest>? Exports { get; init; }
|
|
}
|
|
|
|
public sealed record UpdateMirrorDomainRequest
|
|
{
|
|
public string? DisplayName { get; init; }
|
|
public bool? RequireAuthentication { get; init; }
|
|
public int? MaxIndexRequestsPerHour { get; init; }
|
|
public int? MaxDownloadRequestsPerHour { get; init; }
|
|
public List<CreateMirrorExportRequest>? Exports { get; init; }
|
|
}
|
|
|
|
public sealed record CreateMirrorExportRequest
|
|
{
|
|
public required string Key { get; init; }
|
|
public string? Format { get; init; }
|
|
public Dictionary<string, string>? Filters { get; init; }
|
|
}
|
|
|
|
public sealed record MirrorTestRequest
|
|
{
|
|
public required string BaseAddress { get; init; }
|
|
}
|
|
|
|
public sealed record ConsumerConfigRequest
|
|
{
|
|
public required string BaseAddress { get; init; }
|
|
public required string DomainId { get; init; }
|
|
public string? IndexPath { get; init; }
|
|
public int? HttpTimeoutSeconds { get; init; }
|
|
public ConsumerSignatureRequest? Signature { get; init; }
|
|
}
|
|
|
|
public sealed record ConsumerSignatureRequest
|
|
{
|
|
public bool Enabled { get; init; }
|
|
public string? Algorithm { get; init; }
|
|
public string? KeyId { get; init; }
|
|
public string? PublicKeyPem { get; init; }
|
|
}
|
|
|
|
public sealed record ConsumerDiscoverRequest
|
|
{
|
|
public required string BaseAddress { get; init; }
|
|
public string? IndexPath { get; init; }
|
|
public int? HttpTimeoutSeconds { get; init; }
|
|
}
|
|
|
|
public sealed record ConsumerVerifySignatureRequest
|
|
{
|
|
public required string BaseAddress { get; init; }
|
|
public required string DomainId { get; init; }
|
|
}
|
|
|
|
// ===== Response DTOs =====
|
|
|
|
public sealed record MirrorConfigResponse
|
|
{
|
|
public string Mode { get; init; } = "direct";
|
|
public string? OutputRoot { get; init; }
|
|
public string? ConsumerBaseAddress { get; init; }
|
|
public MirrorSigningResponse? Signing { get; init; }
|
|
public bool AutoRefreshEnabled { get; init; }
|
|
public int RefreshIntervalMinutes { get; init; }
|
|
}
|
|
|
|
public sealed record MirrorSigningResponse
|
|
{
|
|
public bool Enabled { get; init; }
|
|
public string? Algorithm { get; init; }
|
|
public string? KeyId { get; init; }
|
|
}
|
|
|
|
public sealed record MirrorDomainListResponse
|
|
{
|
|
public IReadOnlyList<MirrorDomainSummary> Domains { get; init; } = [];
|
|
public int TotalCount { get; init; }
|
|
}
|
|
|
|
public sealed record MirrorDomainSummary
|
|
{
|
|
public string Id { get; init; } = "";
|
|
public string DisplayName { get; init; } = "";
|
|
public int ExportCount { get; init; }
|
|
public DateTimeOffset? LastGeneratedAt { get; init; }
|
|
public string Staleness { get; init; } = "never_generated";
|
|
}
|
|
|
|
public sealed record MirrorDomainDetailResponse
|
|
{
|
|
public string Id { get; init; } = "";
|
|
public string DisplayName { get; init; } = "";
|
|
public bool RequireAuthentication { get; init; }
|
|
public int MaxIndexRequestsPerHour { get; init; }
|
|
public int MaxDownloadRequestsPerHour { get; init; }
|
|
public IReadOnlyList<MirrorExportSummary> Exports { get; init; } = [];
|
|
public DateTimeOffset? LastGeneratedAt { get; init; }
|
|
public DateTimeOffset CreatedAt { get; init; }
|
|
public DateTimeOffset? UpdatedAt { get; init; }
|
|
}
|
|
|
|
public sealed record MirrorExportSummary
|
|
{
|
|
public string Key { get; init; } = "";
|
|
public string Format { get; init; } = "json";
|
|
public Dictionary<string, string> Filters { get; init; } = new();
|
|
}
|
|
|
|
public sealed record MirrorDomainStatusResponse
|
|
{
|
|
public string DomainId { get; init; } = "";
|
|
public DateTimeOffset? LastGeneratedAt { get; init; }
|
|
public DateTimeOffset? LastGenerateTriggeredAt { get; init; }
|
|
public long BundleSizeBytes { get; init; }
|
|
public long AdvisoryCount { get; init; }
|
|
public int ExportCount { get; init; }
|
|
public string Staleness { get; init; } = "never_generated";
|
|
}
|
|
|
|
public sealed record MirrorTestResponse
|
|
{
|
|
public bool Reachable { get; init; }
|
|
public int? StatusCode { get; init; }
|
|
public string? Message { get; init; }
|
|
}
|
|
|
|
// ===== Consumer connector DTOs =====
|
|
|
|
public sealed record ConsumerConfigResponse
|
|
{
|
|
public string? BaseAddress { get; init; }
|
|
public string? DomainId { get; init; }
|
|
public string IndexPath { get; init; } = "/concelier/exports/index.json";
|
|
public int HttpTimeoutSeconds { get; init; } = 30;
|
|
public ConsumerSignatureResponse? Signature { get; init; }
|
|
public bool Connected { get; init; }
|
|
public DateTimeOffset? LastSync { get; init; }
|
|
}
|
|
|
|
public sealed record ConsumerSignatureResponse
|
|
{
|
|
public bool Enabled { get; init; }
|
|
public string? Algorithm { get; init; }
|
|
public string? KeyId { get; init; }
|
|
public string? PublicKeyPem { get; init; }
|
|
}
|
|
|
|
public sealed record MirrorDiscoveryResponse
|
|
{
|
|
public IReadOnlyList<MirrorDiscoveryDomain> Domains { get; init; } = [];
|
|
public string? Error { get; init; }
|
|
}
|
|
|
|
public sealed record MirrorDiscoveryDomain
|
|
{
|
|
public string DomainId { get; init; } = "";
|
|
public string DisplayName { get; init; } = "";
|
|
public DateTimeOffset? LastGenerated { get; init; }
|
|
public long AdvisoryCount { get; init; }
|
|
public long BundleSize { get; init; }
|
|
public IReadOnlyList<string> ExportFormats { get; init; } = [];
|
|
public bool Signed { get; init; }
|
|
}
|
|
|
|
public sealed record SignatureDiscoveryResponse
|
|
{
|
|
public bool Detected { get; init; }
|
|
public string? Algorithm { get; init; }
|
|
public string? KeyId { get; init; }
|
|
public string? Provider { get; init; }
|
|
public string? Error { get; init; }
|
|
}
|
|
|
|
// ===== Bundle import DTOs =====
|
|
|
|
public sealed record MirrorBundleImportRequest
|
|
{
|
|
public required string BundlePath { get; init; }
|
|
public bool VerifyChecksums { get; init; } = true;
|
|
public bool VerifyDsse { get; init; } = true;
|
|
public string? TrustRootsPath { get; init; }
|
|
}
|
|
|
|
public sealed record MirrorBundleImportAcceptedResponse
|
|
{
|
|
public string ImportId { get; init; } = "";
|
|
public string Status { get; init; } = "running";
|
|
public string BundlePath { get; init; } = "";
|
|
public DateTimeOffset StartedAt { get; init; }
|
|
}
|
|
|
|
public sealed record MirrorBundleImportStatusResponse
|
|
{
|
|
public bool HasImport { get; init; }
|
|
public string? ImportId { get; init; }
|
|
public string Status { get; init; } = "none";
|
|
public string? Message { get; init; }
|
|
public string? BundlePath { get; init; }
|
|
public string? DomainId { get; init; }
|
|
public string? DisplayName { get; init; }
|
|
public DateTimeOffset? StartedAt { get; init; }
|
|
public DateTimeOffset? CompletedAt { get; init; }
|
|
public bool Success { get; init; }
|
|
public int ExportsImported { get; init; }
|
|
public long TotalSize { get; init; }
|
|
public IReadOnlyList<string> Errors { get; init; } = [];
|
|
public IReadOnlyList<string> Warnings { get; init; } = [];
|
|
}
|
|
|
|
/// <summary>
|
|
/// In-memory record for tracking an active or completed import operation.
|
|
/// </summary>
|
|
public sealed class MirrorImportStatusRecord
|
|
{
|
|
public string ImportId { get; set; } = "";
|
|
public string BundlePath { get; set; } = "";
|
|
public string State { get; set; } = "pending";
|
|
public DateTimeOffset StartedAt { get; set; }
|
|
public DateTimeOffset? CompletedAt { get; set; }
|
|
public string? DomainId { get; set; }
|
|
public string? DisplayName { get; set; }
|
|
public bool Success { get; set; }
|
|
public int ExportsImported { get; set; }
|
|
public long TotalSize { get; set; }
|
|
public IReadOnlyList<string> Errors { get; set; } = [];
|
|
public IReadOnlyList<string> Warnings { get; set; } = [];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Simplified manifest DTO for parsing bundle manifests during import.
|
|
/// </summary>
|
|
internal sealed class MirrorBundleManifestDto
|
|
{
|
|
public string? DomainId { get; set; }
|
|
public string? DisplayName { get; set; }
|
|
public DateTimeOffset GeneratedAt { get; set; }
|
|
public List<MirrorBundleExportDto>? Exports { get; set; }
|
|
}
|
|
|
|
internal sealed class MirrorBundleExportDto
|
|
{
|
|
public string? Key { get; set; }
|
|
public string? ExportId { get; set; }
|
|
public string? Format { get; set; }
|
|
public long? ArtifactSizeBytes { get; set; }
|
|
public string? ArtifactDigest { get; set; }
|
|
}
|
|
|
|
// ===== Internal deserialization models =====
|
|
|
|
/// <summary>
|
|
/// Represents the mirror index JSON document fetched during discovery.
|
|
/// </summary>
|
|
internal sealed class MirrorIndexDocument
|
|
{
|
|
public List<MirrorIndexDomainEntry> Domains { get; set; } = [];
|
|
}
|
|
|
|
internal sealed class MirrorIndexDomainEntry
|
|
{
|
|
public string? Id { get; set; }
|
|
public string? DomainId { get; set; }
|
|
public string? DisplayName { get; set; }
|
|
public DateTimeOffset? LastGenerated { get; set; }
|
|
public long AdvisoryCount { get; set; }
|
|
public long BundleSize { get; set; }
|
|
public List<string>? ExportFormats { get; set; }
|
|
public bool Signed { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Minimal JWS header fields used for signature discovery.
|
|
/// </summary>
|
|
internal sealed class JwsHeaderPayload
|
|
{
|
|
public string? Alg { get; set; }
|
|
public string? Kid { get; set; }
|
|
public string? Provider { get; set; }
|
|
}
|