work
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-25 08:01:23 +02:00
parent d92973d6fd
commit 6bee1fdcf5
207 changed files with 12816 additions and 2295 deletions

View File

@@ -26,3 +26,5 @@ Host signed Task Pack bundles with provenance and RBAC for Epic12. Ensure pac
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations.
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.
- 6. Registry API expectations: require `X-API-Key` when configured and tenant scoping via `X-StellaOps-Tenant` (or `tenantId` on upload). Content/provenance downloads must emit digest headers (`X-Content-Digest`, `X-Provenance-Digest`) and respect tenant allowlists.
- 7. Lifecycle/parity/signature rotation endpoints require tenant headers; offline seed export supports per-tenant filtering and deterministic zip output. All mutating calls emit audit log entries (file `audit.ndjson` or Mongo `packs_audit_log`).

View File

@@ -1,6 +0,0 @@
namespace StellaOps.PacksRegistry.Core;
public class Class1
{
}

View File

@@ -0,0 +1,12 @@
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Core.Contracts;
public interface IAttestationRepository
{
Task UpsertAsync(AttestationRecord record, byte[] content, CancellationToken cancellationToken = default);
Task<AttestationRecord?> GetAsync(string packId, string type, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AttestationRecord>> ListAsync(string packId, CancellationToken cancellationToken = default);
Task<byte[]?> GetContentAsync(string packId, string type, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,11 @@
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Core.Contracts;
public interface IAuditRepository
{
Task AppendAsync(AuditRecord record, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AuditRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,12 @@
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Core.Contracts;
public interface ILifecycleRepository
{
Task UpsertAsync(LifecycleRecord record, CancellationToken cancellationToken = default);
Task<LifecycleRecord?> GetAsync(string packId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<LifecycleRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,11 @@
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Core.Contracts;
public interface IMirrorRepository
{
Task UpsertAsync(MirrorSourceRecord record, CancellationToken cancellationToken = default);
Task<IReadOnlyList<MirrorSourceRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
Task<MirrorSourceRecord?> GetAsync(string id, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,16 @@
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Core.Contracts;
public interface IPackRepository
{
Task UpsertAsync(PackRecord record, byte[] content, byte[]? provenance, CancellationToken cancellationToken = default);
Task<PackRecord?> GetAsync(string packId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<PackRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
Task<byte[]?> GetContentAsync(string packId, CancellationToken cancellationToken = default);
Task<byte[]?> GetProvenanceAsync(string packId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.PacksRegistry.Core.Contracts;
public interface IPackSignatureVerifier
{
Task<bool> VerifyAsync(byte[] content, string digest, string? signature, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,12 @@
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Core.Contracts;
public interface IParityRepository
{
Task UpsertAsync(ParityRecord record, CancellationToken cancellationToken = default);
Task<ParityRecord?> GetAsync(string packId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<ParityRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,10 @@
namespace StellaOps.PacksRegistry.Core.Models;
public sealed record AttestationRecord(
string PackId,
string TenantId,
string Type,
string Digest,
DateTimeOffset CreatedAtUtc,
string? Notes = null);

View File

@@ -0,0 +1,13 @@
namespace StellaOps.PacksRegistry.Core.Models;
/// <summary>
/// Immutable audit event emitted for registry actions.
/// </summary>
public sealed record AuditRecord(
string? PackId,
string TenantId,
string Event,
DateTimeOffset OccurredAtUtc,
string? Actor = null,
string? Notes = null);

View File

@@ -0,0 +1,8 @@
namespace StellaOps.PacksRegistry.Core.Models;
public sealed record LifecycleRecord(
string PackId,
string TenantId,
string State,
string? Notes,
DateTimeOffset UpdatedAtUtc);

View File

@@ -0,0 +1,12 @@
namespace StellaOps.PacksRegistry.Core.Models;
public sealed record MirrorSourceRecord(
string Id,
string TenantId,
Uri UpstreamUri,
bool Enabled,
string Status,
DateTimeOffset UpdatedAtUtc,
string? Notes = null,
DateTimeOffset? LastSuccessfulSyncUtc = null);

View File

@@ -0,0 +1,7 @@
namespace StellaOps.PacksRegistry.Core.Models;
public sealed class PackPolicyOptions
{
public bool RequireSignature { get; set; }
}

View File

@@ -0,0 +1,16 @@
namespace StellaOps.PacksRegistry.Core.Models;
/// <summary>
/// Canonical pack metadata stored by the registry.
/// </summary>
public sealed record PackRecord(
string PackId,
string Name,
string Version,
string TenantId,
string Digest,
string? Signature,
string? ProvenanceUri,
string? ProvenanceDigest,
DateTimeOffset CreatedAtUtc,
IReadOnlyDictionary<string, string>? Metadata);

View File

@@ -0,0 +1,8 @@
namespace StellaOps.PacksRegistry.Core.Models;
public sealed record ParityRecord(
string PackId,
string TenantId,
string Status,
string? Notes,
DateTimeOffset UpdatedAtUtc);

View File

@@ -0,0 +1,71 @@
using System.Security.Cryptography;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Core.Services;
public sealed class AttestationService
{
private readonly IPackRepository _packRepository;
private readonly IAttestationRepository _attestationRepository;
private readonly IAuditRepository _auditRepository;
private readonly TimeProvider _timeProvider;
public AttestationService(IPackRepository packRepository, IAttestationRepository attestationRepository, IAuditRepository auditRepository, TimeProvider? timeProvider = null)
{
_packRepository = packRepository ?? throw new ArgumentNullException(nameof(packRepository));
_attestationRepository = attestationRepository ?? throw new ArgumentNullException(nameof(attestationRepository));
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<AttestationRecord> UploadAsync(string packId, string tenantId, string type, byte[] content, string? notes, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(type);
ArgumentNullException.ThrowIfNull(content);
var pack = await _packRepository.GetAsync(packId, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException($"Pack {packId} not found.");
if (!string.Equals(pack.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Tenant mismatch for attestation upload.");
}
var digest = ComputeSha256(content);
var record = new AttestationRecord(packId.Trim(), tenantId.Trim(), type.Trim(), digest, _timeProvider.GetUtcNow(), notes?.Trim());
await _attestationRepository.UpsertAsync(record, content, cancellationToken).ConfigureAwait(false);
await _auditRepository.AppendAsync(new AuditRecord(packId, tenantId, "attestation.uploaded", record.CreatedAtUtc, null, notes), cancellationToken).ConfigureAwait(false);
return record;
}
public Task<AttestationRecord?> GetAsync(string packId, string type, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
ArgumentException.ThrowIfNullOrWhiteSpace(type);
return _attestationRepository.GetAsync(packId.Trim(), type.Trim(), cancellationToken);
}
public Task<IReadOnlyList<AttestationRecord>> ListAsync(string packId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
return _attestationRepository.ListAsync(packId.Trim(), cancellationToken);
}
public Task<byte[]?> GetContentAsync(string packId, string type, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
ArgumentException.ThrowIfNullOrWhiteSpace(type);
return _attestationRepository.GetContentAsync(packId.Trim(), type.Trim(), cancellationToken);
}
private static string ComputeSha256(byte[] bytes)
{
using var sha = SHA256.Create();
var hash = sha.ComputeHash(bytes);
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,36 @@
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Core.Services;
public sealed class ComplianceService
{
private readonly IPackRepository _packRepository;
private readonly IParityRepository _parityRepository;
private readonly ILifecycleRepository _lifecycleRepository;
public ComplianceService(IPackRepository packRepository, IParityRepository parityRepository, ILifecycleRepository lifecycleRepository)
{
_packRepository = packRepository ?? throw new ArgumentNullException(nameof(packRepository));
_parityRepository = parityRepository ?? throw new ArgumentNullException(nameof(parityRepository));
_lifecycleRepository = lifecycleRepository ?? throw new ArgumentNullException(nameof(lifecycleRepository));
}
public async Task<ComplianceSummary> SummarizeAsync(string? tenantId, CancellationToken cancellationToken = default)
{
var packs = await _packRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
var parity = await _parityRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
var lifecycle = await _lifecycleRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
var total = packs.Count;
var unsigned = packs.Count(p => string.IsNullOrWhiteSpace(p.Signature));
var promoted = lifecycle.Count(l => string.Equals(l.State, "promoted", StringComparison.OrdinalIgnoreCase));
var deprecated = lifecycle.Count(l => string.Equals(l.State, "deprecated", StringComparison.OrdinalIgnoreCase));
var parityReady = parity.Count(p => string.Equals(p.Status, "ready", StringComparison.OrdinalIgnoreCase));
return new ComplianceSummary(total, unsigned, promoted, deprecated, parityReady);
}
}
public sealed record ComplianceSummary(int TotalPacks, int UnsignedPacks, int PromotedPacks, int DeprecatedPacks, int ParityReadyPacks);

View File

@@ -0,0 +1,104 @@
using System.IO.Compression;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Core.Services;
public sealed class ExportService
{
private static readonly DateTimeOffset ZipEpoch = new DateTimeOffset(1980, 1, 1, 0, 0, 0, TimeSpan.Zero);
private readonly IPackRepository _packRepository;
private readonly IParityRepository _parityRepository;
private readonly ILifecycleRepository _lifecycleRepository;
private readonly IAuditRepository _auditRepository;
private readonly TimeProvider _timeProvider;
private readonly JsonSerializerOptions _jsonOptions;
public ExportService(
IPackRepository packRepository,
IParityRepository parityRepository,
ILifecycleRepository lifecycleRepository,
IAuditRepository auditRepository,
TimeProvider? timeProvider = null)
{
_packRepository = packRepository ?? throw new ArgumentNullException(nameof(packRepository));
_parityRepository = parityRepository ?? throw new ArgumentNullException(nameof(parityRepository));
_lifecycleRepository = lifecycleRepository ?? throw new ArgumentNullException(nameof(lifecycleRepository));
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
_timeProvider = timeProvider ?? TimeProvider.System;
_jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };
}
public async Task<MemoryStream> ExportOfflineSeedAsync(string? tenantId, bool includeContent, bool includeProvenance, CancellationToken cancellationToken = default)
{
var packs = await _packRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
var parity = await _parityRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
var lifecycle = await _lifecycleRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
var audits = await _auditRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
var stream = new MemoryStream();
using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true))
{
WriteNdjson(archive, "packs.ndjson", packs.OrderBy(p => p.PackId, StringComparer.Ordinal));
WriteNdjson(archive, "parity.ndjson", parity.OrderBy(p => p.PackId, StringComparer.Ordinal));
WriteNdjson(archive, "lifecycle.ndjson", lifecycle.OrderBy(l => l.PackId, StringComparer.Ordinal));
WriteNdjson(archive, "audit.ndjson", audits.OrderBy(a => a.OccurredAtUtc).ThenBy(a => a.PackId, StringComparer.Ordinal));
if (includeContent)
{
foreach (var pack in packs.OrderBy(p => p.PackId, StringComparer.Ordinal))
{
var content = await _packRepository.GetContentAsync(pack.PackId, cancellationToken).ConfigureAwait(false);
if (content is null || content.Length == 0)
{
continue;
}
var entry = archive.CreateEntry($"content/{pack.PackId}.bin", CompressionLevel.Optimal);
entry.LastWriteTime = ZipEpoch;
await using var entryStream = entry.Open();
await entryStream.WriteAsync(content.AsMemory(0, content.Length), cancellationToken).ConfigureAwait(false);
}
}
if (includeProvenance)
{
foreach (var pack in packs.OrderBy(p => p.PackId, StringComparer.Ordinal))
{
var provenance = await _packRepository.GetProvenanceAsync(pack.PackId, cancellationToken).ConfigureAwait(false);
if (provenance is null || provenance.Length == 0)
{
continue;
}
var entry = archive.CreateEntry($"provenance/{pack.PackId}.json", CompressionLevel.Optimal);
entry.LastWriteTime = ZipEpoch;
await using var entryStream = entry.Open();
await entryStream.WriteAsync(provenance.AsMemory(0, provenance.Length), cancellationToken).ConfigureAwait(false);
}
}
}
stream.Position = 0;
var auditTenant = string.IsNullOrWhiteSpace(tenantId) ? "*" : tenantId.Trim();
await _auditRepository.AppendAsync(new AuditRecord(null, auditTenant, "offline.seed.exported", _timeProvider.GetUtcNow(), null, includeContent ? "with-content" : null), cancellationToken).ConfigureAwait(false);
return stream;
}
private void WriteNdjson<T>(ZipArchive archive, string entryName, IEnumerable<T> records)
{
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
entry.LastWriteTime = ZipEpoch;
using var stream = entry.Open();
using var writer = new StreamWriter(stream);
foreach (var record in records)
{
var json = JsonSerializer.Serialize(record, _jsonOptions);
writer.WriteLine(json);
}
}
}

View File

@@ -0,0 +1,62 @@
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Core.Services;
public sealed class LifecycleService
{
private readonly ILifecycleRepository _lifecycleRepository;
private readonly IPackRepository _packRepository;
private readonly IAuditRepository _auditRepository;
private readonly TimeProvider _timeProvider;
private static readonly HashSet<string> AllowedStates = new(StringComparer.OrdinalIgnoreCase)
{
"promoted", "deprecated", "draft"
};
public LifecycleService(ILifecycleRepository lifecycleRepository, IPackRepository packRepository, IAuditRepository auditRepository, TimeProvider? timeProvider = null)
{
_lifecycleRepository = lifecycleRepository ?? throw new ArgumentNullException(nameof(lifecycleRepository));
_packRepository = packRepository ?? throw new ArgumentNullException(nameof(packRepository));
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<LifecycleRecord> SetStateAsync(string packId, string tenantId, string state, string? notes, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(state);
if (!AllowedStates.Contains(state))
{
throw new InvalidOperationException($"State '{state}' is not allowed (use: {string.Join(',', AllowedStates)}).");
}
var pack = await _packRepository.GetAsync(packId, cancellationToken).ConfigureAwait(false);
if (pack is null)
{
throw new InvalidOperationException($"Pack {packId} not found.");
}
if (!string.Equals(pack.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Tenant mismatch for lifecycle update.");
}
var record = new LifecycleRecord(packId.Trim(), tenantId.Trim(), state.Trim(), notes?.Trim(), _timeProvider.GetUtcNow());
await _lifecycleRepository.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
await _auditRepository.AppendAsync(new AuditRecord(packId, tenantId, "lifecycle.updated", record.UpdatedAtUtc, null, notes), cancellationToken).ConfigureAwait(false);
return record;
}
public Task<LifecycleRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
return _lifecycleRepository.GetAsync(packId.Trim(), cancellationToken);
}
public Task<IReadOnlyList<LifecycleRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
=> _lifecycleRepository.ListAsync(tenantId?.Trim(), cancellationToken);
}

View File

@@ -0,0 +1,59 @@
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Core.Services;
public sealed class MirrorService
{
private readonly IMirrorRepository _mirrorRepository;
private readonly IAuditRepository _auditRepository;
private readonly TimeProvider _timeProvider;
public MirrorService(IMirrorRepository mirrorRepository, IAuditRepository auditRepository, TimeProvider? timeProvider = null)
{
_mirrorRepository = mirrorRepository ?? throw new ArgumentNullException(nameof(mirrorRepository));
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<MirrorSourceRecord> UpsertAsync(string id, string tenantId, Uri upstreamUri, bool enabled, string? notes, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id);
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(upstreamUri);
var record = new MirrorSourceRecord(id.Trim(), tenantId.Trim(), upstreamUri, enabled, enabled ? "enabled" : "disabled", _timeProvider.GetUtcNow(), notes?.Trim(), null);
await _mirrorRepository.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
await _auditRepository.AppendAsync(new AuditRecord(null, tenantId, "mirror.upserted", record.UpdatedAtUtc, null, upstreamUri.ToString()), cancellationToken).ConfigureAwait(false);
return record;
}
public Task<IReadOnlyList<MirrorSourceRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
=> _mirrorRepository.ListAsync(tenantId?.Trim(), cancellationToken);
public async Task<MirrorSourceRecord?> MarkSyncAsync(string id, string tenantId, string status, string? notes, CancellationToken cancellationToken = default)
{
var existing = await _mirrorRepository.GetAsync(id.Trim(), cancellationToken).ConfigureAwait(false);
if (existing is null)
{
return null;
}
if (!string.Equals(existing.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Tenant mismatch for mirror sync update.");
}
var updated = existing with
{
Status = status.Trim(),
UpdatedAtUtc = _timeProvider.GetUtcNow(),
LastSuccessfulSyncUtc = string.Equals(status, "synced", StringComparison.OrdinalIgnoreCase) ? _timeProvider.GetUtcNow() : existing.LastSuccessfulSyncUtc,
Notes = notes ?? existing.Notes
};
await _mirrorRepository.UpsertAsync(updated, cancellationToken).ConfigureAwait(false);
await _auditRepository.AppendAsync(new AuditRecord(null, tenantId, "mirror.sync", updated.UpdatedAtUtc, null, status), cancellationToken).ConfigureAwait(false);
return updated;
}
}

View File

@@ -0,0 +1,149 @@
using System.Security.Cryptography;
using System.Text;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Core.Services;
public sealed class PackService
{
private readonly IPackRepository _repository;
private readonly IPackSignatureVerifier _signatureVerifier;
private readonly IAuditRepository _auditRepository;
private readonly TimeProvider _timeProvider;
private readonly PackPolicyOptions _policy;
public PackService(IPackRepository repository, IPackSignatureVerifier signatureVerifier, IAuditRepository auditRepository, PackPolicyOptions? policy = null, TimeProvider? timeProvider = null)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_signatureVerifier = signatureVerifier ?? throw new ArgumentNullException(nameof(signatureVerifier));
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
_policy = policy ?? new PackPolicyOptions();
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<PackRecord> UploadAsync(
string name,
string version,
string tenantId,
byte[] content,
string? signature,
string? provenanceUri,
byte[]? provenanceContent,
IReadOnlyDictionary<string, string>? metadata,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(content);
var digest = ComputeSha256(content);
if (_policy.RequireSignature && string.IsNullOrWhiteSpace(signature))
{
throw new InvalidOperationException("Signature is required by policy.");
}
var valid = await _signatureVerifier.VerifyAsync(content, digest, signature, cancellationToken).ConfigureAwait(false);
if (!valid)
{
throw new InvalidOperationException("Signature validation failed for uploaded pack.");
}
string? provenanceDigest = null;
if (provenanceContent is { Length: > 0 })
{
provenanceDigest = ComputeSha256(provenanceContent);
}
var packId = BuildPackId(name, version);
var record = new PackRecord(
PackId: packId,
Name: name.Trim(),
Version: version.Trim(),
TenantId: tenantId.Trim(),
Digest: digest,
Signature: signature,
ProvenanceUri: provenanceUri,
ProvenanceDigest: provenanceDigest,
CreatedAtUtc: _timeProvider.GetUtcNow(),
Metadata: metadata);
await _repository.UpsertAsync(record, content, provenanceContent, cancellationToken).ConfigureAwait(false);
await _auditRepository.AppendAsync(new AuditRecord(packId, tenantId, "pack.uploaded", record.CreatedAtUtc, null, provenanceUri), cancellationToken).ConfigureAwait(false);
return record;
}
public Task<PackRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
return _repository.GetAsync(packId.Trim(), cancellationToken);
}
public Task<byte[]?> GetContentAsync(string packId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
return _repository.GetContentAsync(packId.Trim(), cancellationToken);
}
public Task<byte[]?> GetProvenanceAsync(string packId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
return _repository.GetProvenanceAsync(packId.Trim(), cancellationToken);
}
public Task<IReadOnlyList<PackRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
=> _repository.ListAsync(tenantId?.Trim(), cancellationToken);
public async Task<PackRecord> RotateSignatureAsync(
string packId,
string tenantId,
string newSignature,
IPackSignatureVerifier? verifierOverride = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(newSignature);
var record = await _repository.GetAsync(packId.Trim(), cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException($"Pack {packId} not found.");
if (!string.Equals(record.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Tenant mismatch for signature rotation.");
}
var content = await _repository.GetContentAsync(packId.Trim(), cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Pack content missing; cannot rotate signature.");
var digest = ComputeSha256(content);
var verifier = verifierOverride ?? _signatureVerifier;
var valid = await verifier.VerifyAsync(content, digest, newSignature, cancellationToken).ConfigureAwait(false);
if (!valid)
{
throw new InvalidOperationException("Signature validation failed during rotation.");
}
var provenance = await _repository.GetProvenanceAsync(packId.Trim(), cancellationToken).ConfigureAwait(false);
var updated = record with { Signature = newSignature, Digest = digest };
await _repository.UpsertAsync(updated, content, provenance, cancellationToken).ConfigureAwait(false);
await _auditRepository.AppendAsync(new AuditRecord(packId, tenantId, "signature.rotated", _timeProvider.GetUtcNow(), null, null), cancellationToken).ConfigureAwait(false);
return updated;
}
private static string ComputeSha256(byte[] bytes)
{
using var sha = SHA256.Create();
var hash = sha.ComputeHash(bytes);
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
}
private static string BuildPackId(string name, string version)
{
var cleanName = name.Trim().ToLowerInvariant().Replace(' ', '-');
var cleanVersion = version.Trim().ToLowerInvariant();
return $"{cleanName}@{cleanVersion}";
}
}

View File

@@ -0,0 +1,52 @@
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Core.Services;
public sealed class ParityService
{
private readonly IParityRepository _parityRepository;
private readonly IPackRepository _packRepository;
private readonly IAuditRepository _auditRepository;
private readonly TimeProvider _timeProvider;
public ParityService(IParityRepository parityRepository, IPackRepository packRepository, IAuditRepository auditRepository, TimeProvider? timeProvider = null)
{
_parityRepository = parityRepository ?? throw new ArgumentNullException(nameof(parityRepository));
_packRepository = packRepository ?? throw new ArgumentNullException(nameof(packRepository));
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<ParityRecord> SetStatusAsync(string packId, string tenantId, string status, string? notes, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(status);
var pack = await _packRepository.GetAsync(packId, cancellationToken).ConfigureAwait(false);
if (pack is null)
{
throw new InvalidOperationException($"Pack {packId} not found.");
}
if (!string.Equals(pack.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Tenant mismatch for parity update.");
}
var record = new ParityRecord(packId.Trim(), tenantId.Trim(), status.Trim(), notes?.Trim(), _timeProvider.GetUtcNow());
await _parityRepository.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
await _auditRepository.AppendAsync(new AuditRecord(packId, tenantId, "parity.updated", record.UpdatedAtUtc, null, notes), cancellationToken).ConfigureAwait(false);
return record;
}
public Task<ParityRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
return _parityRepository.GetAsync(packId.Trim(), cancellationToken);
}
public Task<IReadOnlyList<ParityRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
=> _parityRepository.ListAsync(tenantId?.Trim(), cancellationToken);
}

View File

@@ -1,6 +0,0 @@
namespace StellaOps.PacksRegistry.Infrastructure;
public class Class1
{
}

View File

@@ -0,0 +1,92 @@
using System.Text.Json;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Infrastructure.FileSystem;
public sealed class FileAttestationRepository : IAttestationRepository
{
private readonly string _indexPath;
private readonly string _contentPath;
private readonly JsonSerializerOptions _jsonOptions;
private readonly SemaphoreSlim _mutex = new(1, 1);
public FileAttestationRepository(string rootPath)
{
var root = string.IsNullOrWhiteSpace(rootPath) ? Path.GetFullPath("data/packs") : Path.GetFullPath(rootPath);
Directory.CreateDirectory(root);
_indexPath = Path.Combine(root, "attestations.ndjson");
_contentPath = Path.Combine(root, "attestations");
Directory.CreateDirectory(_contentPath);
_jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
}
public async Task UpsertAsync(AttestationRecord record, byte[] content, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(content);
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await using var stream = new FileStream(_indexPath, FileMode.Append, FileAccess.Write, FileShare.Read);
await using var writer = new StreamWriter(stream);
var json = JsonSerializer.Serialize(record, _jsonOptions);
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
var fileName = GetFileName(record.PackId, record.Type);
await File.WriteAllBytesAsync(fileName, content, cancellationToken).ConfigureAwait(false);
}
finally
{
_mutex.Release();
}
}
public async Task<AttestationRecord?> GetAsync(string packId, string type, CancellationToken cancellationToken = default)
{
var list = await ListAsync(packId, cancellationToken).ConfigureAwait(false);
return list.LastOrDefault(r => string.Equals(r.Type, type, StringComparison.OrdinalIgnoreCase));
}
public async Task<IReadOnlyList<AttestationRecord>> ListAsync(string packId, CancellationToken cancellationToken = default)
{
if (!File.Exists(_indexPath))
{
return Array.Empty<AttestationRecord>();
}
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var lines = await File.ReadAllLinesAsync(_indexPath, cancellationToken).ConfigureAwait(false);
return lines
.Where(l => !string.IsNullOrWhiteSpace(l))
.Select(l => JsonSerializer.Deserialize<AttestationRecord>(l, _jsonOptions))
.Where(r => r is not null && string.Equals(r!.PackId, packId, StringComparison.OrdinalIgnoreCase))
.Cast<AttestationRecord>()
.OrderBy(r => r.Type, StringComparer.OrdinalIgnoreCase)
.ToList();
}
finally
{
_mutex.Release();
}
}
public Task<byte[]?> GetContentAsync(string packId, string type, CancellationToken cancellationToken = default)
{
var file = GetFileName(packId, type);
if (!File.Exists(file))
{
return Task.FromResult<byte[]?>(null);
}
return File.ReadAllBytesAsync(file, cancellationToken).ContinueWith(t => (byte[]?)t.Result, cancellationToken);
}
private string GetFileName(string packId, string type)
{
var safe = packId.Replace('/', '_').Replace('@', '_');
var safeType = type.Replace('/', '_');
return Path.Combine(_contentPath, $"{safe}_{safeType}.bin");
}
}

View File

@@ -0,0 +1,71 @@
using System.Text.Json;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Infrastructure.FileSystem;
public sealed class FileAuditRepository : IAuditRepository
{
private readonly string _path;
private readonly JsonSerializerOptions _jsonOptions;
private readonly SemaphoreSlim _mutex = new(1, 1);
public FileAuditRepository(string rootPath)
{
var root = string.IsNullOrWhiteSpace(rootPath) ? Path.GetFullPath("data/packs") : Path.GetFullPath(rootPath);
Directory.CreateDirectory(root);
_path = Path.Combine(root, "audit.ndjson");
_jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
}
public async Task AppendAsync(AuditRecord record, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(record);
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await using var stream = new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.Read);
await using var writer = new StreamWriter(stream);
var json = JsonSerializer.Serialize(record, _jsonOptions);
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
}
finally
{
_mutex.Release();
}
}
public async Task<IReadOnlyList<AuditRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
if (!File.Exists(_path))
{
return Array.Empty<AuditRecord>();
}
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var lines = await File.ReadAllLinesAsync(_path, cancellationToken).ConfigureAwait(false);
IEnumerable<AuditRecord> records = lines
.Where(l => !string.IsNullOrWhiteSpace(l))
.Select(l => JsonSerializer.Deserialize<AuditRecord>(l, _jsonOptions))
.Where(r => r is not null)!
.Cast<AuditRecord>();
if (!string.IsNullOrWhiteSpace(tenantId))
{
records = records.Where(r => string.Equals(r.TenantId, tenantId, StringComparison.OrdinalIgnoreCase));
}
return records
.OrderBy(r => r.OccurredAtUtc)
.ThenBy(r => r.PackId, StringComparer.OrdinalIgnoreCase)
.ToList();
}
finally
{
_mutex.Release();
}
}
}

View File

@@ -0,0 +1,91 @@
using System.Text.Json;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Infrastructure.FileSystem;
public sealed class FileLifecycleRepository : ILifecycleRepository
{
private readonly string _path;
private readonly JsonSerializerOptions _jsonOptions;
private readonly SemaphoreSlim _mutex = new(1, 1);
public FileLifecycleRepository(string rootPath)
{
var root = string.IsNullOrWhiteSpace(rootPath) ? Path.GetFullPath("data/packs") : Path.GetFullPath(rootPath);
Directory.CreateDirectory(root);
_path = Path.Combine(root, "lifecycle.ndjson");
_jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
}
public async Task UpsertAsync(LifecycleRecord record, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(record);
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await using var stream = new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.Read);
await using var writer = new StreamWriter(stream);
var json = JsonSerializer.Serialize(record, _jsonOptions);
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
}
finally
{
_mutex.Release();
}
}
public async Task<LifecycleRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
{
if (!File.Exists(_path))
{
return null;
}
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var lines = await File.ReadAllLinesAsync(_path, cancellationToken).ConfigureAwait(false);
return lines
.Where(l => !string.IsNullOrWhiteSpace(l))
.Select(l => JsonSerializer.Deserialize<LifecycleRecord>(l, _jsonOptions))
.Where(r => r is not null)
.Cast<LifecycleRecord>()
.LastOrDefault(r => string.Equals(r.PackId, packId, StringComparison.OrdinalIgnoreCase));
}
finally
{
_mutex.Release();
}
}
public async Task<IReadOnlyList<LifecycleRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
if (!File.Exists(_path))
{
return Array.Empty<LifecycleRecord>();
}
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var lines = await File.ReadAllLinesAsync(_path, cancellationToken).ConfigureAwait(false);
IEnumerable<LifecycleRecord> records = lines
.Where(l => !string.IsNullOrWhiteSpace(l))
.Select(l => JsonSerializer.Deserialize<LifecycleRecord>(l, _jsonOptions))
.Where(r => r is not null)!
.Cast<LifecycleRecord>();
if (!string.IsNullOrWhiteSpace(tenantId))
{
records = records.Where(r => string.Equals(r.TenantId, tenantId, StringComparison.OrdinalIgnoreCase));
}
return records.OrderBy(r => r.PackId, StringComparer.OrdinalIgnoreCase).ToList();
}
finally
{
_mutex.Release();
}
}
}

View File

@@ -0,0 +1,74 @@
using System.Text.Json;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Infrastructure.FileSystem;
public sealed class FileMirrorRepository : IMirrorRepository
{
private readonly string _path;
private readonly JsonSerializerOptions _jsonOptions;
private readonly SemaphoreSlim _mutex = new(1, 1);
public FileMirrorRepository(string rootPath)
{
var root = string.IsNullOrWhiteSpace(rootPath) ? Path.GetFullPath("data/packs") : Path.GetFullPath(rootPath);
Directory.CreateDirectory(root);
_path = Path.Combine(root, "mirrors.ndjson");
_jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
}
public async Task UpsertAsync(MirrorSourceRecord record, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(record);
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await using var stream = new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.Read);
await using var writer = new StreamWriter(stream);
var json = JsonSerializer.Serialize(record, _jsonOptions);
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
}
finally
{
_mutex.Release();
}
}
public async Task<IReadOnlyList<MirrorSourceRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
if (!File.Exists(_path))
{
return Array.Empty<MirrorSourceRecord>();
}
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var lines = await File.ReadAllLinesAsync(_path, cancellationToken).ConfigureAwait(false);
IEnumerable<MirrorSourceRecord> records = lines
.Where(l => !string.IsNullOrWhiteSpace(l))
.Select(l => JsonSerializer.Deserialize<MirrorSourceRecord>(l, _jsonOptions))
.Where(r => r is not null)!
.Cast<MirrorSourceRecord>();
if (!string.IsNullOrWhiteSpace(tenantId))
{
records = records.Where(r => string.Equals(r.TenantId, tenantId, StringComparison.OrdinalIgnoreCase));
}
return records.OrderBy(r => r.Id, StringComparer.OrdinalIgnoreCase).ToList();
}
finally
{
_mutex.Release();
}
}
public async Task<MirrorSourceRecord?> GetAsync(string id, CancellationToken cancellationToken = default)
{
var list = await ListAsync(null, cancellationToken).ConfigureAwait(false);
return list.LastOrDefault(r => string.Equals(r.Id, id, StringComparison.OrdinalIgnoreCase));
}
}

View File

@@ -0,0 +1,134 @@
using System.Text.Json;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Infrastructure.FileSystem;
public sealed class FilePackRepository : IPackRepository
{
private readonly string _root;
private readonly string _indexPath;
private readonly JsonSerializerOptions _jsonOptions;
private readonly SemaphoreSlim _mutex = new(1, 1);
public FilePackRepository(string root)
{
_root = string.IsNullOrWhiteSpace(root) ? Path.GetFullPath("data/packs") : Path.GetFullPath(root);
_indexPath = Path.Combine(_root, "index.ndjson");
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
Directory.CreateDirectory(_root);
Directory.CreateDirectory(Path.Combine(_root, "blobs"));
}
public async Task UpsertAsync(PackRecord record, byte[] content, byte[]? provenance, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(content);
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var blobPath = Path.Combine(_root, "blobs", record.Digest.Replace(':', '_'));
await File.WriteAllBytesAsync(blobPath, content, cancellationToken).ConfigureAwait(false);
if (provenance is { Length: > 0 } && record.ProvenanceDigest is not null)
{
var provPath = Path.Combine(_root, "provenance", record.ProvenanceDigest.Replace(':', '_'));
Directory.CreateDirectory(Path.GetDirectoryName(provPath)!);
await File.WriteAllBytesAsync(provPath, provenance, cancellationToken).ConfigureAwait(false);
}
await using var stream = new FileStream(_indexPath, FileMode.Append, FileAccess.Write, FileShare.Read);
await using var writer = new StreamWriter(stream);
var json = JsonSerializer.Serialize(record, _jsonOptions);
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
}
finally
{
_mutex.Release();
}
}
public async Task<PackRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
var records = await ReadAllAsync(cancellationToken).ConfigureAwait(false);
return records.LastOrDefault(r => string.Equals(r.PackId, packId, StringComparison.OrdinalIgnoreCase));
}
public async Task<IReadOnlyList<PackRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
var records = await ReadAllAsync(cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(tenantId))
{
records = records.Where(r => string.Equals(r.TenantId, tenantId, StringComparison.OrdinalIgnoreCase));
}
return records
.OrderBy(r => r.TenantId, StringComparer.OrdinalIgnoreCase)
.ThenBy(r => r.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(r => r.Version, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private async Task<IEnumerable<PackRecord>> ReadAllAsync(CancellationToken cancellationToken)
{
if (!File.Exists(_indexPath))
{
return Array.Empty<PackRecord>();
}
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var lines = await File.ReadAllLinesAsync(_indexPath, cancellationToken).ConfigureAwait(false);
return lines
.Where(line => !string.IsNullOrWhiteSpace(line))
.Select(line => JsonSerializer.Deserialize<PackRecord>(line, _jsonOptions))
.Where(r => r is not null)!;
}
finally
{
_mutex.Release();
}
}
public async Task<byte[]?> GetContentAsync(string packId, CancellationToken cancellationToken = default)
{
var record = await GetAsync(packId, cancellationToken).ConfigureAwait(false);
if (record is null)
{
return null;
}
var blobPath = Path.Combine(_root, "blobs", record.Digest.Replace(':', '_'));
if (!File.Exists(blobPath))
{
return null;
}
return await File.ReadAllBytesAsync(blobPath, cancellationToken).ConfigureAwait(false);
}
public async Task<byte[]?> GetProvenanceAsync(string packId, CancellationToken cancellationToken = default)
{
var record = await GetAsync(packId, cancellationToken).ConfigureAwait(false);
if (record?.ProvenanceDigest is null)
{
return null;
}
var provPath = Path.Combine(_root, "provenance", record.ProvenanceDigest.Replace(':', '_'));
if (!File.Exists(provPath))
{
return null;
}
return await File.ReadAllBytesAsync(provPath, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,92 @@
using System.Text.Json;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Infrastructure.FileSystem;
public sealed class FileParityRepository : IParityRepository
{
private readonly string _path;
private readonly JsonSerializerOptions _jsonOptions;
private readonly SemaphoreSlim _mutex = new(1, 1);
public FileParityRepository(string rootPath)
{
var root = string.IsNullOrWhiteSpace(rootPath) ? Path.GetFullPath("data/packs") : Path.GetFullPath(rootPath);
Directory.CreateDirectory(root);
_path = Path.Combine(root, "parity.ndjson");
_jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
}
public async Task UpsertAsync(ParityRecord record, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(record);
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// naive append; last write wins on read
await using var stream = new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.Read);
await using var writer = new StreamWriter(stream);
var json = JsonSerializer.Serialize(record, _jsonOptions);
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
}
finally
{
_mutex.Release();
}
}
public async Task<ParityRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
{
if (!File.Exists(_path))
{
return null;
}
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var lines = await File.ReadAllLinesAsync(_path, cancellationToken).ConfigureAwait(false);
return lines
.Where(l => !string.IsNullOrWhiteSpace(l))
.Select(l => JsonSerializer.Deserialize<ParityRecord>(l, _jsonOptions))
.Where(r => r is not null)
.Cast<ParityRecord>()
.LastOrDefault(r => string.Equals(r.PackId, packId, StringComparison.OrdinalIgnoreCase));
}
finally
{
_mutex.Release();
}
}
public async Task<IReadOnlyList<ParityRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
if (!File.Exists(_path))
{
return Array.Empty<ParityRecord>();
}
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var lines = await File.ReadAllLinesAsync(_path, cancellationToken).ConfigureAwait(false);
IEnumerable<ParityRecord> records = lines
.Where(l => !string.IsNullOrWhiteSpace(l))
.Select(l => JsonSerializer.Deserialize<ParityRecord>(l, _jsonOptions))
.Where(r => r is not null)!
.Cast<ParityRecord>();
if (!string.IsNullOrWhiteSpace(tenantId))
{
records = records.Where(r => string.Equals(r.TenantId, tenantId, StringComparison.OrdinalIgnoreCase));
}
return records.OrderBy(r => r.PackId, StringComparer.OrdinalIgnoreCase).ToList();
}
finally
{
_mutex.Release();
}
}
}

View File

@@ -0,0 +1,41 @@
using System.Collections.Concurrent;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Infrastructure.InMemory;
public sealed class InMemoryAttestationRepository : IAttestationRepository
{
private readonly ConcurrentDictionary<(string PackId, string Type), AttestationRecord> _records = new();
private readonly ConcurrentDictionary<(string PackId, string Type), byte[]> _content = new();
public Task UpsertAsync(AttestationRecord record, byte[] content, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(content);
_records[(record.PackId, record.Type)] = record;
_content[(record.PackId, record.Type)] = content.ToArray();
return Task.CompletedTask;
}
public Task<AttestationRecord?> GetAsync(string packId, string type, CancellationToken cancellationToken = default)
{
_records.TryGetValue((packId, type), out var record);
return Task.FromResult(record);
}
public Task<IReadOnlyList<AttestationRecord>> ListAsync(string packId, CancellationToken cancellationToken = default)
{
var result = _records.Values.Where(r => string.Equals(r.PackId, packId, StringComparison.OrdinalIgnoreCase))
.OrderBy(r => r.Type, StringComparer.OrdinalIgnoreCase)
.ToList();
return Task.FromResult<IReadOnlyList<AttestationRecord>>(result);
}
public Task<byte[]?> GetContentAsync(string packId, string type, CancellationToken cancellationToken = default)
{
_content.TryGetValue((packId, type), out var bytes);
return Task.FromResult<byte[]?>(bytes?.ToArray());
}
}

View File

@@ -0,0 +1,34 @@
using System.Collections.Concurrent;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Infrastructure.InMemory;
public sealed class InMemoryAuditRepository : IAuditRepository
{
private readonly ConcurrentBag<AuditRecord> _events = new();
public Task AppendAsync(AuditRecord record, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(record);
_events.Add(record);
return Task.CompletedTask;
}
public Task<IReadOnlyList<AuditRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
IEnumerable<AuditRecord> result = _events;
if (!string.IsNullOrWhiteSpace(tenantId))
{
result = result.Where(e => string.Equals(e.TenantId, tenantId, StringComparison.OrdinalIgnoreCase));
}
var ordered = result
.OrderBy(e => e.OccurredAtUtc)
.ThenBy(e => e.PackId, StringComparer.OrdinalIgnoreCase)
.ToList();
return Task.FromResult<IReadOnlyList<AuditRecord>>(ordered);
}
}

View File

@@ -0,0 +1,35 @@
using System.Collections.Concurrent;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Infrastructure.InMemory;
public sealed class InMemoryLifecycleRepository : ILifecycleRepository
{
private readonly ConcurrentDictionary<string, LifecycleRecord> _records = new(StringComparer.OrdinalIgnoreCase);
public Task UpsertAsync(LifecycleRecord record, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(record);
_records[record.PackId] = record;
return Task.CompletedTask;
}
public Task<LifecycleRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
{
_records.TryGetValue(packId, out var record);
return Task.FromResult(record);
}
public Task<IReadOnlyList<LifecycleRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
IEnumerable<LifecycleRecord> result = _records.Values;
if (!string.IsNullOrWhiteSpace(tenantId))
{
result = result.Where(r => string.Equals(r.TenantId, tenantId, StringComparison.OrdinalIgnoreCase));
}
var ordered = result.OrderBy(r => r.PackId, StringComparer.OrdinalIgnoreCase).ToList();
return Task.FromResult<IReadOnlyList<LifecycleRecord>>(ordered);
}
}

View File

@@ -0,0 +1,35 @@
using System.Collections.Concurrent;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Infrastructure.InMemory;
public sealed class InMemoryMirrorRepository : IMirrorRepository
{
private readonly ConcurrentDictionary<string, MirrorSourceRecord> _records = new(StringComparer.OrdinalIgnoreCase);
public Task UpsertAsync(MirrorSourceRecord record, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(record);
_records[record.Id] = record;
return Task.CompletedTask;
}
public Task<IReadOnlyList<MirrorSourceRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
IEnumerable<MirrorSourceRecord> result = _records.Values;
if (!string.IsNullOrWhiteSpace(tenantId))
{
result = result.Where(r => string.Equals(r.TenantId, tenantId, StringComparison.OrdinalIgnoreCase));
}
return Task.FromResult<IReadOnlyList<MirrorSourceRecord>>(result.OrderBy(r => r.Id, StringComparer.OrdinalIgnoreCase).ToList());
}
public Task<MirrorSourceRecord?> GetAsync(string id, CancellationToken cancellationToken = default)
{
_records.TryGetValue(id, out var record);
return Task.FromResult(record);
}
}

View File

@@ -0,0 +1,71 @@
using System.Collections.Concurrent;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Infrastructure.InMemory;
/// <summary>
/// Simple in-memory repository for early development and tests.
/// </summary>
public sealed class InMemoryPackRepository : IPackRepository
{
private readonly ConcurrentDictionary<string, PackRecord> _packs = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, byte[]> _content = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, byte[]> _provenance = new(StringComparer.OrdinalIgnoreCase);
public Task UpsertAsync(PackRecord record, byte[] content, byte[]? provenance, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(content);
_packs[record.PackId] = record;
_content[record.Digest] = content.ToArray();
if (provenance is { Length: > 0 } && !string.IsNullOrWhiteSpace(record.ProvenanceDigest))
{
_provenance[record.ProvenanceDigest!] = provenance.ToArray();
}
return Task.CompletedTask;
}
public Task<PackRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
{
_packs.TryGetValue(packId, out var record);
return Task.FromResult(record);
}
public Task<IReadOnlyList<PackRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
IEnumerable<PackRecord> result = _packs.Values;
if (!string.IsNullOrWhiteSpace(tenantId))
{
result = result.Where(p => string.Equals(p.TenantId, tenantId, StringComparison.OrdinalIgnoreCase));
}
var ordered = result
.OrderBy(p => p.TenantId, StringComparer.OrdinalIgnoreCase)
.ThenBy(p => p.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(p => p.Version, StringComparer.OrdinalIgnoreCase)
.ToArray();
return Task.FromResult<IReadOnlyList<PackRecord>>(ordered);
}
public Task<byte[]?> GetContentAsync(string packId, CancellationToken cancellationToken = default)
{
if (_packs.TryGetValue(packId, out var record) && _content.TryGetValue(record.Digest, out var bytes))
{
return Task.FromResult<byte[]?>(bytes.ToArray());
}
return Task.FromResult<byte[]?>(null);
}
public Task<byte[]?> GetProvenanceAsync(string packId, CancellationToken cancellationToken = default)
{
if (_packs.TryGetValue(packId, out var record) && record.ProvenanceDigest is not null && _provenance.TryGetValue(record.ProvenanceDigest, out var bytes))
{
return Task.FromResult<byte[]?>(bytes.ToArray());
}
return Task.FromResult<byte[]?>(null);
}
}

View File

@@ -0,0 +1,35 @@
using System.Collections.Concurrent;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Infrastructure.InMemory;
public sealed class InMemoryParityRepository : IParityRepository
{
private readonly ConcurrentDictionary<string, ParityRecord> _parity = new(StringComparer.OrdinalIgnoreCase);
public Task UpsertAsync(ParityRecord record, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(record);
_parity[record.PackId] = record;
return Task.CompletedTask;
}
public Task<ParityRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
{
_parity.TryGetValue(packId, out var record);
return Task.FromResult(record);
}
public Task<IReadOnlyList<ParityRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
IEnumerable<ParityRecord> result = _parity.Values;
if (!string.IsNullOrWhiteSpace(tenantId))
{
result = result.Where(r => string.Equals(r.TenantId, tenantId, StringComparison.OrdinalIgnoreCase));
}
var ordered = result.OrderBy(r => r.PackId, StringComparer.OrdinalIgnoreCase).ToList();
return Task.FromResult<IReadOnlyList<ParityRecord>>(ordered);
}
}

View File

@@ -0,0 +1,84 @@
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
using StellaOps.PacksRegistry.Infrastructure.Options;
namespace StellaOps.PacksRegistry.Infrastructure.Mongo;
public sealed class MongoAttestationRepository : IAttestationRepository
{
private readonly IMongoCollection<AttestationDocument> _index;
private readonly IMongoCollection<AttestationBlob> _blobs;
public MongoAttestationRepository(IMongoDatabase database, MongoOptions options)
{
_index = database.GetCollection<AttestationDocument>(options.AttestationCollection ?? "packs_attestations");
_blobs = database.GetCollection<AttestationBlob>(options.AttestationBlobsCollection ?? "packs_attestation_blobs");
_index.Indexes.CreateOne(new CreateIndexModel<AttestationDocument>(Builders<AttestationDocument>.IndexKeys.Ascending(x => x.PackId).Ascending(x => x.Type), new CreateIndexOptions { Unique = true }));
_blobs.Indexes.CreateOne(new CreateIndexModel<AttestationBlob>(Builders<AttestationBlob>.IndexKeys.Ascending(x => x.Digest), new CreateIndexOptions { Unique = true }));
}
public async Task UpsertAsync(AttestationRecord record, byte[] content, CancellationToken cancellationToken = default)
{
var doc = AttestationDocument.From(record);
await _index.ReplaceOneAsync(x => x.PackId == record.PackId && x.Type == record.Type, doc, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
var blob = new AttestationBlob { Digest = record.Digest, Content = content };
await _blobs.ReplaceOneAsync(x => x.Digest == blob.Digest, blob, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
public async Task<AttestationRecord?> GetAsync(string packId, string type, CancellationToken cancellationToken = default)
{
var doc = await _index.Find(x => x.PackId == packId && x.Type == type).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return doc?.ToModel();
}
public async Task<IReadOnlyList<AttestationRecord>> ListAsync(string packId, CancellationToken cancellationToken = default)
{
var docs = await _index.Find(x => x.PackId == packId).SortBy(x => x.Type).ToListAsync(cancellationToken).ConfigureAwait(false);
return docs.Select(d => d.ToModel()).ToList();
}
public async Task<byte[]?> GetContentAsync(string packId, string type, CancellationToken cancellationToken = default)
{
var record = await GetAsync(packId, type, cancellationToken).ConfigureAwait(false);
if (record is null)
{
return null;
}
var blob = await _blobs.Find(x => x.Digest == record.Digest).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return blob?.Content;
}
private sealed class AttestationDocument
{
public ObjectId Id { get; set; }
public string PackId { get; set; } = default!;
public string TenantId { get; set; } = default!;
public string Type { get; set; } = default!;
public string Digest { get; set; } = default!;
public DateTimeOffset CreatedAtUtc { get; set; }
public string? Notes { get; set; }
public AttestationRecord ToModel() => new(PackId, TenantId, Type, Digest, CreatedAtUtc, Notes);
public static AttestationDocument From(AttestationRecord record) => new()
{
PackId = record.PackId,
TenantId = record.TenantId,
Type = record.Type,
Digest = record.Digest,
CreatedAtUtc = record.CreatedAtUtc,
Notes = record.Notes
};
}
private sealed class AttestationBlob
{
public ObjectId Id { get; set; }
public string Digest { get; set; } = default!;
public byte[] Content { get; set; } = default!;
}
}

View File

@@ -0,0 +1,66 @@
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
using StellaOps.PacksRegistry.Infrastructure.Options;
namespace StellaOps.PacksRegistry.Infrastructure.Mongo;
public sealed class MongoAuditRepository : IAuditRepository
{
private readonly IMongoCollection<AuditDocument> _collection;
public MongoAuditRepository(IMongoDatabase database, MongoOptions options)
{
_collection = database.GetCollection<AuditDocument>(options.AuditCollection ?? "packs_audit_log");
var indexKeys = Builders<AuditDocument>.IndexKeys
.Ascending(x => x.TenantId)
.Ascending(x => x.PackId)
.Ascending(x => x.OccurredAtUtc);
_collection.Indexes.CreateOne(new CreateIndexModel<AuditDocument>(indexKeys));
}
public async Task AppendAsync(AuditRecord record, CancellationToken cancellationToken = default)
{
var doc = AuditDocument.From(record);
await _collection.InsertOneAsync(doc, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<AuditRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
var filter = string.IsNullOrWhiteSpace(tenantId)
? Builders<AuditDocument>.Filter.Empty
: Builders<AuditDocument>.Filter.Eq(x => x.TenantId, tenantId);
var docs = await _collection.Find(filter)
.SortBy(x => x.OccurredAtUtc)
.ThenBy(x => x.PackId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return docs.Select(d => d.ToModel()).ToList();
}
private sealed class AuditDocument
{
public ObjectId Id { get; set; }
public string? PackId { get; set; }
public string TenantId { get; set; } = default!;
public string Event { get; set; } = default!;
public DateTimeOffset OccurredAtUtc { get; set; }
public string? Actor { get; set; }
public string? Notes { get; set; }
public AuditRecord ToModel() => new(PackId, TenantId, Event, OccurredAtUtc, Actor, Notes);
public static AuditDocument From(AuditRecord record) => new()
{
PackId = record.PackId,
TenantId = record.TenantId,
Event = record.Event,
OccurredAtUtc = record.OccurredAtUtc,
Actor = record.Actor,
Notes = record.Notes
};
}
}

View File

@@ -0,0 +1,64 @@
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
using StellaOps.PacksRegistry.Infrastructure.Options;
namespace StellaOps.PacksRegistry.Infrastructure.Mongo;
public sealed class MongoLifecycleRepository : ILifecycleRepository
{
private readonly IMongoCollection<LifecycleDocument> _collection;
public MongoLifecycleRepository(IMongoDatabase database, MongoOptions options)
{
_collection = database.GetCollection<LifecycleDocument>(options.LifecycleCollection ?? "packs_lifecycle");
_collection.Indexes.CreateOne(new CreateIndexModel<LifecycleDocument>(Builders<LifecycleDocument>.IndexKeys.Ascending(x => x.PackId), new CreateIndexOptions { Unique = true }));
}
public async Task UpsertAsync(LifecycleRecord record, CancellationToken cancellationToken = default)
{
var doc = LifecycleDocument.From(record);
await _collection.ReplaceOneAsync(x => x.PackId == record.PackId, doc, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
public async Task<LifecycleRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
{
var doc = await _collection.Find(x => x.PackId == packId).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return doc?.ToModel();
}
public async Task<IReadOnlyList<LifecycleRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
var filter = string.IsNullOrWhiteSpace(tenantId)
? Builders<LifecycleDocument>.Filter.Empty
: Builders<LifecycleDocument>.Filter.Eq(x => x.TenantId, tenantId);
var docs = await _collection.Find(filter)
.SortBy(x => x.PackId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return docs.Select(d => d.ToModel()).ToList();
}
private sealed class LifecycleDocument
{
public ObjectId Id { get; set; }
public string PackId { get; set; } = default!;
public string TenantId { get; set; } = default!;
public string State { get; set; } = default!;
public string? Notes { get; set; }
public DateTimeOffset UpdatedAtUtc { get; set; }
public LifecycleRecord ToModel() => new(PackId, TenantId, State, Notes, UpdatedAtUtc);
public static LifecycleDocument From(LifecycleRecord record) => new()
{
PackId = record.PackId,
TenantId = record.TenantId,
State = record.State,
Notes = record.Notes,
UpdatedAtUtc = record.UpdatedAtUtc
};
}
}

View File

@@ -0,0 +1,67 @@
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
using StellaOps.PacksRegistry.Infrastructure.Options;
namespace StellaOps.PacksRegistry.Infrastructure.Mongo;
public sealed class MongoMirrorRepository : IMirrorRepository
{
private readonly IMongoCollection<MirrorDocument> _collection;
public MongoMirrorRepository(IMongoDatabase database, MongoOptions options)
{
_collection = database.GetCollection<MirrorDocument>(options.MirrorCollection ?? "packs_mirrors");
_collection.Indexes.CreateOne(new CreateIndexModel<MirrorDocument>(Builders<MirrorDocument>.IndexKeys.Ascending(x => x.Id), new CreateIndexOptions { Unique = true }));
}
public async Task UpsertAsync(MirrorSourceRecord record, CancellationToken cancellationToken = default)
{
var doc = MirrorDocument.From(record);
await _collection.ReplaceOneAsync(x => x.Id == record.Id, doc, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<MirrorSourceRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
var filter = string.IsNullOrWhiteSpace(tenantId)
? Builders<MirrorDocument>.Filter.Empty
: Builders<MirrorDocument>.Filter.Eq(x => x.TenantId, tenantId);
var docs = await _collection.Find(filter).SortBy(x => x.Id).ToListAsync(cancellationToken).ConfigureAwait(false);
return docs.Select(d => d.ToModel()).ToList();
}
public async Task<MirrorSourceRecord?> GetAsync(string id, CancellationToken cancellationToken = default)
{
var doc = await _collection.Find(x => x.Id == id).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return doc?.ToModel();
}
private sealed class MirrorDocument
{
public ObjectId InternalId { get; set; }
public string Id { get; set; } = default!;
public string TenantId { get; set; } = default!;
public string Upstream { get; set; } = default!;
public bool Enabled { get; set; }
public string Status { get; set; } = default!;
public DateTimeOffset UpdatedAtUtc { get; set; }
public string? Notes { get; set; }
public DateTimeOffset? LastSuccessfulSyncUtc { get; set; }
public MirrorSourceRecord ToModel() => new(Id, TenantId, new Uri(Upstream), Enabled, Status, UpdatedAtUtc, Notes, LastSuccessfulSyncUtc);
public static MirrorDocument From(MirrorSourceRecord record) => new()
{
Id = record.Id,
TenantId = record.TenantId,
Upstream = record.UpstreamUri.ToString(),
Enabled = record.Enabled,
Status = record.Status,
UpdatedAtUtc = record.UpdatedAtUtc,
Notes = record.Notes,
LastSuccessfulSyncUtc = record.LastSuccessfulSyncUtc
};
}
}

View File

@@ -0,0 +1,123 @@
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
using StellaOps.PacksRegistry.Infrastructure.Options;
namespace StellaOps.PacksRegistry.Infrastructure.Mongo;
public sealed class MongoPackRepository : IPackRepository
{
private readonly IMongoCollection<PackDocument> _packs;
private readonly IMongoCollection<PackContentDocument> _contents;
public MongoPackRepository(IMongoDatabase database, MongoOptions options)
{
ArgumentNullException.ThrowIfNull(database);
_packs = database.GetCollection<PackDocument>(options.PacksCollection);
_contents = database.GetCollection<PackContentDocument>(options.BlobsCollection);
_packs.Indexes.CreateOne(new CreateIndexModel<PackDocument>(Builders<PackDocument>.IndexKeys.Ascending(x => x.PackId), new CreateIndexOptions { Unique = true }));
_packs.Indexes.CreateOne(new CreateIndexModel<PackDocument>(Builders<PackDocument>.IndexKeys.Ascending(x => x.TenantId).Ascending(x => x.Name).Ascending(x => x.Version)));
_contents.Indexes.CreateOne(new CreateIndexModel<PackContentDocument>(Builders<PackContentDocument>.IndexKeys.Ascending(x => x.Digest), new CreateIndexOptions { Unique = true }));
}
public async Task UpsertAsync(PackRecord record, byte[] content, byte[]? provenance, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(content);
var packDoc = PackDocument.From(record);
await _packs.ReplaceOneAsync(x => x.PackId == record.PackId, packDoc, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
var blob = new PackContentDocument
{
Digest = record.Digest,
Content = content,
ProvenanceDigest = record.ProvenanceDigest,
Provenance = provenance
};
await _contents.ReplaceOneAsync(x => x.Digest == record.Digest, blob, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
public async Task<PackRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
{
var doc = await _packs.Find(x => x.PackId == packId).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return doc?.ToModel();
}
public async Task<IReadOnlyList<PackRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
var filter = string.IsNullOrWhiteSpace(tenantId)
? Builders<PackDocument>.Filter.Empty
: Builders<PackDocument>.Filter.Eq(x => x.TenantId, tenantId);
var docs = await _packs.Find(filter).SortBy(x => x.TenantId).ThenBy(x => x.Name).ThenBy(x => x.Version).ToListAsync(cancellationToken).ConfigureAwait(false);
return docs.Select(d => d.ToModel()).ToArray();
}
public async Task<byte[]?> GetContentAsync(string packId, CancellationToken cancellationToken = default)
{
var pack = await GetAsync(packId, cancellationToken).ConfigureAwait(false);
if (pack is null)
{
return null;
}
var blob = await _contents.Find(x => x.Digest == pack.Digest).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return blob?.Content;
}
public async Task<byte[]?> GetProvenanceAsync(string packId, CancellationToken cancellationToken = default)
{
var pack = await GetAsync(packId, cancellationToken).ConfigureAwait(false);
if (pack is null || string.IsNullOrWhiteSpace(pack.ProvenanceDigest))
{
return null;
}
var blob = await _contents.Find(x => x.Digest == pack.Digest).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return blob?.Provenance;
}
private sealed class PackDocument
{
public ObjectId Id { get; set; }
public string PackId { get; set; } = default!;
public string Name { get; set; } = default!;
public string Version { get; set; } = default!;
public string TenantId { get; set; } = default!;
public string Digest { get; set; } = default!;
public string? Signature { get; set; }
public string? ProvenanceUri { get; set; }
public string? ProvenanceDigest { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; }
public Dictionary<string, string>? Metadata { get; set; }
public PackRecord ToModel() => new(PackId, Name, Version, TenantId, Digest, Signature, ProvenanceUri, ProvenanceDigest, CreatedAtUtc, Metadata);
public static PackDocument From(PackRecord model) => new()
{
PackId = model.PackId,
Name = model.Name,
Version = model.Version,
TenantId = model.TenantId,
Digest = model.Digest,
Signature = model.Signature,
ProvenanceUri = model.ProvenanceUri,
ProvenanceDigest = model.ProvenanceDigest,
CreatedAtUtc = model.CreatedAtUtc,
Metadata = model.Metadata?.ToDictionary(kv => kv.Key, kv => kv.Value)
};
}
private sealed class PackContentDocument
{
public ObjectId Id { get; set; }
public string Digest { get; set; } = default!;
public byte[] Content { get; set; } = Array.Empty<byte>();
public string? ProvenanceDigest { get; set; }
public byte[]? Provenance { get; set; }
}
}

View File

@@ -0,0 +1,64 @@
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
using StellaOps.PacksRegistry.Infrastructure.Options;
namespace StellaOps.PacksRegistry.Infrastructure.Mongo;
public sealed class MongoParityRepository : IParityRepository
{
private readonly IMongoCollection<ParityDocument> _collection;
public MongoParityRepository(IMongoDatabase database, MongoOptions options)
{
_collection = database.GetCollection<ParityDocument>(options.ParityCollection ?? "packs_parity_matrix");
_collection.Indexes.CreateOne(new CreateIndexModel<ParityDocument>(Builders<ParityDocument>.IndexKeys.Ascending(x => x.PackId), new CreateIndexOptions { Unique = true }));
}
public async Task UpsertAsync(ParityRecord record, CancellationToken cancellationToken = default)
{
var doc = ParityDocument.From(record);
await _collection.ReplaceOneAsync(x => x.PackId == record.PackId, doc, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
public async Task<ParityRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
{
var doc = await _collection.Find(x => x.PackId == packId).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return doc?.ToModel();
}
public async Task<IReadOnlyList<ParityRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
var filter = string.IsNullOrWhiteSpace(tenantId)
? Builders<ParityDocument>.Filter.Empty
: Builders<ParityDocument>.Filter.Eq(x => x.TenantId, tenantId);
var docs = await _collection.Find(filter)
.SortBy(x => x.PackId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return docs.Select(d => d.ToModel()).ToList();
}
private sealed class ParityDocument
{
public ObjectId Id { get; set; }
public string PackId { get; set; } = default!;
public string TenantId { get; set; } = default!;
public string Status { get; set; } = default!;
public string? Notes { get; set; }
public DateTimeOffset UpdatedAtUtc { get; set; }
public ParityRecord ToModel() => new(PackId, TenantId, Status, Notes, UpdatedAtUtc);
public static ParityDocument From(ParityRecord record) => new()
{
PackId = record.PackId,
TenantId = record.TenantId,
Status = record.Status,
Notes = record.Notes,
UpdatedAtUtc = record.UpdatedAtUtc
};
}
}

View File

@@ -0,0 +1,109 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.PacksRegistry.Infrastructure.Options;
namespace StellaOps.PacksRegistry.Infrastructure.Mongo;
/// <summary>
/// Ensures Mongo collections and indexes exist for packs, blobs, and parity matrix.
/// </summary>
public sealed class PacksMongoInitializer : IHostedService
{
private readonly IMongoDatabase _database;
private readonly MongoOptions _options;
private readonly ILogger<PacksMongoInitializer> _logger;
public PacksMongoInitializer(IMongoDatabase database, MongoOptions options, ILogger<PacksMongoInitializer> logger)
{
_database = database ?? throw new ArgumentNullException(nameof(database));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task StartAsync(CancellationToken cancellationToken)
{
await EnsurePacksIndexAsync(cancellationToken).ConfigureAwait(false);
await EnsureBlobsIndexAsync(cancellationToken).ConfigureAwait(false);
await EnsureParityMatrixAsync(cancellationToken).ConfigureAwait(false);
await EnsureLifecycleAsync(cancellationToken).ConfigureAwait(false);
await EnsureAuditAsync(cancellationToken).ConfigureAwait(false);
await EnsureAttestationsAsync(cancellationToken).ConfigureAwait(false);
await EnsureMirrorsAsync(cancellationToken).ConfigureAwait(false);
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
private async Task EnsurePacksIndexAsync(CancellationToken cancellationToken)
{
var packs = _database.GetCollection<BsonDocument>(_options.PacksCollection);
var indexKeys = Builders<BsonDocument>.IndexKeys.Ascending("packId");
await packs.Indexes.CreateOneAsync(new CreateIndexModel<BsonDocument>(indexKeys, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken).ConfigureAwait(false);
var secondary = Builders<BsonDocument>.IndexKeys.Ascending("tenantId").Ascending("name").Ascending("version");
await packs.Indexes.CreateOneAsync(new CreateIndexModel<BsonDocument>(secondary), cancellationToken: cancellationToken).ConfigureAwait(false);
}
private async Task EnsureBlobsIndexAsync(CancellationToken cancellationToken)
{
var blobs = _database.GetCollection<BsonDocument>(_options.BlobsCollection);
var indexKeys = Builders<BsonDocument>.IndexKeys.Ascending("digest");
await blobs.Indexes.CreateOneAsync(new CreateIndexModel<BsonDocument>(indexKeys, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken).ConfigureAwait(false);
}
private async Task EnsureParityMatrixAsync(CancellationToken cancellationToken)
{
var parityName = _options.ParityCollection ?? "packs_parity_matrix";
var parity = _database.GetCollection<BsonDocument>(parityName);
var indexKeys = Builders<BsonDocument>.IndexKeys.Ascending("packId");
await parity.Indexes.CreateOneAsync(new CreateIndexModel<BsonDocument>(indexKeys, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Mongo collections ensured: {Packs}, {Blobs}, {Parity}", _options.PacksCollection, _options.BlobsCollection, parityName);
}
private async Task EnsureLifecycleAsync(CancellationToken cancellationToken)
{
var lifecycleName = _options.LifecycleCollection ?? "packs_lifecycle";
var lifecycle = _database.GetCollection<BsonDocument>(lifecycleName);
var indexKeys = Builders<BsonDocument>.IndexKeys.Ascending("packId");
await lifecycle.Indexes.CreateOneAsync(new CreateIndexModel<BsonDocument>(indexKeys, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Mongo lifecycle collection ensured: {Lifecycle}", lifecycleName);
}
private async Task EnsureAuditAsync(CancellationToken cancellationToken)
{
var auditName = _options.AuditCollection ?? "packs_audit_log";
var audit = _database.GetCollection<BsonDocument>(auditName);
var indexKeys = Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("packId")
.Ascending("occurredAtUtc");
await audit.Indexes.CreateOneAsync(new CreateIndexModel<BsonDocument>(indexKeys), cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Mongo audit collection ensured: {Audit}", auditName);
}
private async Task EnsureAttestationsAsync(CancellationToken cancellationToken)
{
var attestName = _options.AttestationCollection ?? "packs_attestations";
var attest = _database.GetCollection<BsonDocument>(attestName);
var indexKeys = Builders<BsonDocument>.IndexKeys.Ascending("packId").Ascending("type");
await attest.Indexes.CreateOneAsync(new CreateIndexModel<BsonDocument>(indexKeys, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken).ConfigureAwait(false);
var blobsName = _options.AttestationBlobsCollection ?? "packs_attestation_blobs";
var blobs = _database.GetCollection<BsonDocument>(blobsName);
var blobIndex = Builders<BsonDocument>.IndexKeys.Ascending("digest");
await blobs.Indexes.CreateOneAsync(new CreateIndexModel<BsonDocument>(blobIndex, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Mongo attestation collections ensured: {Attest} / {AttestBlobs}", attestName, blobsName);
}
private async Task EnsureMirrorsAsync(CancellationToken cancellationToken)
{
var mirrorName = _options.MirrorCollection ?? "packs_mirrors";
var mirrors = _database.GetCollection<BsonDocument>(mirrorName);
var indexKeys = Builders<BsonDocument>.IndexKeys.Ascending("id");
await mirrors.Indexes.CreateOneAsync(new CreateIndexModel<BsonDocument>(indexKeys, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Mongo mirror collection ensured: {Mirror}", mirrorName);
}
}

View File

@@ -0,0 +1,15 @@
namespace StellaOps.PacksRegistry.Infrastructure.Options;
public sealed class MongoOptions
{
public string? ConnectionString { get; set; }
public string Database { get; set; } = "packs_registry";
public string PacksCollection { get; set; } = "packs_index";
public string BlobsCollection { get; set; } = "packs_blobs";
public string? ParityCollection { get; set; } = "packs_parity_matrix";
public string? LifecycleCollection { get; set; } = "packs_lifecycle";
public string? AuditCollection { get; set; } = "packs_audit_log";
public string? AttestationCollection { get; set; } = "packs_attestations";
public string? AttestationBlobsCollection { get; set; } = "packs_attestation_blobs";
public string? MirrorCollection { get; set; } = "packs_mirrors";
}

View File

@@ -3,14 +3,22 @@
<ItemGroup>
<ProjectReference Include="..\StellaOps.PacksRegistry.Core\StellaOps.PacksRegistry.Core.csproj"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.PacksRegistry.Core\StellaOps.PacksRegistry.Core.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0-rc.2.25502.107" />
</ItemGroup>
<PropertyGroup>

View File

@@ -0,0 +1,50 @@
using System.Security.Cryptography;
using System.Text;
using StellaOps.PacksRegistry.Core.Contracts;
namespace StellaOps.PacksRegistry.Infrastructure.Verification;
/// <summary>
/// Verifies signatures over the digest string using an RSA public key (PEM, PKCS#8 or PKCS#1), SHA-256.
/// Signature input is expected to be base64 of the raw RSA signature over UTF-8 digest text (e.g. "sha256:abcd...").
/// </summary>
public sealed class RsaSignatureVerifier : IPackSignatureVerifier
{
private readonly RSA _rsa;
public RsaSignatureVerifier(string publicKeyPem)
{
if (string.IsNullOrWhiteSpace(publicKeyPem))
{
throw new ArgumentException("Public key PEM is required for RSA verification.", nameof(publicKeyPem));
}
_rsa = RSA.Create();
_rsa.ImportFromPem(publicKeyPem);
}
public Task<bool> VerifyAsync(byte[] content, string digest, string? signature, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(content);
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
if (string.IsNullOrWhiteSpace(signature))
{
return Task.FromResult(false);
}
byte[] sigBytes;
try
{
sigBytes = Convert.FromBase64String(signature);
}
catch
{
return Task.FromResult(false);
}
var data = Encoding.UTF8.GetBytes(digest);
var valid = _rsa.VerifyData(data, sigBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
return Task.FromResult(valid);
}
}

View File

@@ -0,0 +1,46 @@
using System.Security.Cryptography;
using System.Text;
using StellaOps.PacksRegistry.Core.Contracts;
namespace StellaOps.PacksRegistry.Infrastructure.Verification;
/// <summary>
/// Accepts either no signature or a signature that matches the computed SHA-256 digest (hex or base64 of digest string).
/// Replace with real signature verification when keys/attestations are available.
/// </summary>
public sealed class SimpleSignatureVerifier : IPackSignatureVerifier
{
public Task<bool> VerifyAsync(byte[] content, string digest, string? signature, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(content);
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
using var sha = SHA256.Create();
var computed = "sha256:" + Convert.ToHexString(sha.ComputeHash(content)).ToLowerInvariant();
if (string.IsNullOrWhiteSpace(signature))
{
return Task.FromResult(string.Equals(computed, digest, StringComparison.OrdinalIgnoreCase));
}
if (string.Equals(signature, computed, StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(true);
}
try
{
var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(signature));
if (string.Equals(decoded, computed, StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(true);
}
}
catch
{
// ignore decode errors
}
return Task.FromResult(false);
}
}

View File

@@ -0,0 +1,41 @@
using System.IO.Compression;
using StellaOps.PacksRegistry.Core.Services;
using StellaOps.PacksRegistry.Infrastructure.InMemory;
using StellaOps.PacksRegistry.Infrastructure.Verification;
namespace StellaOps.PacksRegistry.Tests;
public sealed class ExportServiceTests
{
[Fact]
public async Task Offline_seed_includes_metadata_and_content_when_requested()
{
var ct = TestContext.Current.CancellationToken;
var packRepo = new InMemoryPackRepository();
var parityRepo = new InMemoryParityRepository();
var lifecycleRepo = new InMemoryLifecycleRepository();
var auditRepo = new InMemoryAuditRepository();
var verifier = new SimpleSignatureVerifier();
var packService = new PackService(packRepo, verifier, auditRepo, null, TimeProvider.System);
var parityService = new ParityService(parityRepo, packRepo, auditRepo, TimeProvider.System);
var lifecycleService = new LifecycleService(lifecycleRepo, packRepo, auditRepo, TimeProvider.System);
var exportService = new ExportService(packRepo, parityRepo, lifecycleRepo, auditRepo, TimeProvider.System);
var content = System.Text.Encoding.UTF8.GetBytes("export-pack");
var provenance = System.Text.Encoding.UTF8.GetBytes("{\"p\":1}");
var record = await packService.UploadAsync("demo", "1.2.3", "tenant-1", content, null, null, provenance, null, ct);
await parityService.SetStatusAsync(record.PackId, record.TenantId, "ready", "seed", ct);
await lifecycleService.SetStateAsync(record.PackId, record.TenantId, "promoted", "seed", ct);
var archiveStream = await exportService.ExportOfflineSeedAsync(record.TenantId, includeContent: true, includeProvenance: true, cancellationToken: ct);
using var archive = new ZipArchive(archiveStream, ZipArchiveMode.Read);
Assert.NotNull(archive.GetEntry("packs.ndjson"));
Assert.NotNull(archive.GetEntry("parity.ndjson"));
Assert.NotNull(archive.GetEntry("lifecycle.ndjson"));
Assert.NotNull(archive.GetEntry("audit.ndjson"));
Assert.NotNull(archive.GetEntry($"content/{record.PackId}.bin"));
Assert.NotNull(archive.GetEntry($"provenance/{record.PackId}.json"));
}
}

View File

@@ -0,0 +1,45 @@
using StellaOps.PacksRegistry.Core.Models;
using StellaOps.PacksRegistry.Infrastructure.FileSystem;
namespace StellaOps.PacksRegistry.Tests;
public sealed class FilePackRepositoryTests
{
[Fact]
public async Task Upsert_and_List_round_trip()
{
var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempPath);
try
{
var ct = TestContext.Current.CancellationToken;
var repo = new FilePackRepository(tempPath);
var record = new PackRecord(
PackId: "demo@1.0.0",
Name: "demo",
Version: "1.0.0",
TenantId: "t1",
Digest: "sha256:abc",
Signature: null,
ProvenanceUri: null,
ProvenanceDigest: null,
CreatedAtUtc: DateTimeOffset.Parse("2025-11-24T00:00:00Z"),
Metadata: new Dictionary<string, string> { ["lang"] = "csharp" });
await repo.UpsertAsync(record, new byte[] { 1, 2, 3 }, null, ct);
var listed = await repo.ListAsync("t1", ct);
Assert.Single(listed);
Assert.Equal(record.PackId, listed[0].PackId);
var fetched = await repo.GetAsync("demo@1.0.0", ct);
Assert.NotNull(fetched);
Assert.Equal(record.Digest, fetched!.Digest);
}
finally
{
Directory.Delete(tempPath, recursive: true);
}
}
}

View File

@@ -0,0 +1,95 @@
using StellaOps.PacksRegistry.Core.Services;
using StellaOps.PacksRegistry.Infrastructure.InMemory;
using StellaOps.PacksRegistry.Infrastructure.Verification;
namespace StellaOps.PacksRegistry.Tests;
public sealed class PackServiceTests
{
private static byte[] SampleContent => System.Text.Encoding.UTF8.GetBytes("sample-pack-content");
[Fact]
public async Task Upload_persists_pack_with_digest()
{
var ct = TestContext.Current.CancellationToken;
var repo = new InMemoryPackRepository();
var verifier = new SimpleSignatureVerifier();
var service = new PackService(repo, verifier, new InMemoryAuditRepository(), null, TimeProvider.System);
var record = await service.UploadAsync(
name: "demo-pack",
version: "1.0.0",
tenantId: "tenant-1",
content: SampleContent,
signature: null,
provenanceUri: "https://example/manifest.json",
provenanceContent: null,
metadata: new Dictionary<string, string> { ["lang"] = "csharp" },
cancellationToken: ct);
Assert.Equal("demo-pack@1.0.0", record.PackId);
Assert.NotNull(record.Digest);
var listed = await service.ListAsync("tenant-1", ct);
Assert.Single(listed);
Assert.Equal(record.PackId, listed[0].PackId);
}
[Fact]
public async Task Upload_rejects_when_digest_mismatch()
{
var ct = TestContext.Current.CancellationToken;
var repo = new InMemoryPackRepository();
var verifier = new AlwaysFailSignatureVerifier();
var service = new PackService(repo, verifier, new InMemoryAuditRepository(), null, TimeProvider.System);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
service.UploadAsync(
name: "demo-pack",
version: "1.0.0",
tenantId: "tenant-1",
content: SampleContent,
signature: "bogus",
provenanceUri: null,
provenanceContent: null,
metadata: null,
cancellationToken: ct));
}
[Fact]
public async Task Rotate_signature_updates_record_and_audits()
{
var ct = TestContext.Current.CancellationToken;
var repo = new InMemoryPackRepository();
var audit = new InMemoryAuditRepository();
var verifier = new SimpleSignatureVerifier();
var service = new PackService(repo, verifier, audit, null, TimeProvider.System);
var record = await service.UploadAsync(
name: "demo-pack",
version: "1.0.0",
tenantId: "tenant-1",
content: SampleContent,
signature: null,
provenanceUri: null,
provenanceContent: null,
metadata: null,
cancellationToken: ct);
var digest = record.Digest;
var newSignature = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(digest));
var rotated = await service.RotateSignatureAsync(record.PackId, record.TenantId, newSignature, cancellationToken: ct);
Assert.Equal(newSignature, rotated.Signature);
var auditEvents = await audit.ListAsync(record.TenantId, ct);
Assert.Contains(auditEvents, a => a.Event == "signature.rotated" && a.PackId == record.PackId);
}
private sealed class AlwaysFailSignatureVerifier : StellaOps.PacksRegistry.Core.Contracts.IPackSignatureVerifier
{
public Task<bool> VerifyAsync(byte[] content, string digest, string? signature, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
}
}

View File

@@ -0,0 +1,127 @@
using System.Net;
using System.Net.Http.Json;
using System.IO.Compression;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Services;
using StellaOps.PacksRegistry.Infrastructure.InMemory;
using StellaOps.PacksRegistry.WebService.Contracts;
namespace StellaOps.PacksRegistry.Tests;
public sealed class PacksApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public PacksApiTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.RemoveAll<IPackRepository>();
services.RemoveAll<IParityRepository>();
services.RemoveAll<ILifecycleRepository>();
services.RemoveAll<IAuditRepository>();
services.AddSingleton<IPackRepository, InMemoryPackRepository>();
services.AddSingleton<IParityRepository, InMemoryParityRepository>();
services.AddSingleton<ILifecycleRepository, InMemoryLifecycleRepository>();
services.AddSingleton<IAuditRepository, InMemoryAuditRepository>();
services.AddSingleton(TimeProvider.System);
services.RemoveAll<PackService>();
services.RemoveAll<ParityService>();
services.RemoveAll<LifecycleService>();
services.RemoveAll<ExportService>();
services.AddSingleton<PackService>();
services.AddSingleton<ParityService>();
services.AddSingleton<LifecycleService>();
services.AddSingleton<ExportService>();
});
});
}
[Fact]
public async Task Upload_and_download_round_trip()
{
var ct = TestContext.Current.CancellationToken;
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "t1");
var auth = _factory.Services.GetRequiredService<StellaOps.PacksRegistry.WebService.Options.AuthOptions>();
if (!string.IsNullOrWhiteSpace(auth.ApiKey))
{
client.DefaultRequestHeaders.Add("X-API-Key", auth.ApiKey);
}
var payload = new PackUploadRequest
{
Name = "demo",
Version = "1.0.0",
TenantId = "t1",
Content = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("hello")),
ProvenanceUri = "https://example/provenance.json",
ProvenanceContent = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{\"provenance\":true}"))
};
var message = new HttpRequestMessage(HttpMethod.Post, "/api/v1/packs")
{
Content = JsonContent.Create(payload)
};
var response = await client.SendAsync(message, ct);
if (response.StatusCode != HttpStatusCode.Created)
{
var body = await response.Content.ReadAsStringAsync(ct);
throw new InvalidOperationException($"Upload failed with {response.StatusCode}: {body}");
}
var created = await response.Content.ReadFromJsonAsync<PackResponse>(cancellationToken: ct);
Assert.NotNull(created);
Assert.Equal("demo", created!.Name);
Assert.Equal("1.0.0", created.Version);
var get = await client.GetAsync($"/api/v1/packs/{created.PackId}", ct);
Assert.Equal(HttpStatusCode.OK, get.StatusCode);
var content = await client.GetAsync($"/api/v1/packs/{created.PackId}/content", ct);
Assert.Equal(HttpStatusCode.OK, content.StatusCode);
var bytes = await content.Content.ReadAsByteArrayAsync(ct);
Assert.Equal("hello", System.Text.Encoding.UTF8.GetString(bytes));
Assert.True(content.Headers.Contains("X-Content-Digest"));
var prov = await client.GetAsync($"/api/v1/packs/{created.PackId}/provenance", ct);
Assert.Equal(HttpStatusCode.OK, prov.StatusCode);
var provBytes = await prov.Content.ReadAsByteArrayAsync(ct);
Assert.Contains("provenance", System.Text.Encoding.UTF8.GetString(provBytes));
Assert.True(prov.Headers.Contains("X-Provenance-Digest"));
var manifest = await client.GetFromJsonAsync<PackManifestResponse>($"/api/v1/packs/{created.PackId}/manifest", ct);
Assert.NotNull(manifest);
Assert.Equal(created.PackId, manifest!.PackId);
Assert.True(manifest.ContentLength > 0);
Assert.True(manifest.ProvenanceLength > 0);
// parity status
var parityResponse = await client.PostAsJsonAsync($"/api/v1/packs/{created.PackId}/parity", new ParityRequest { Status = "ready", Notes = "tests" }, ct);
Assert.Equal(HttpStatusCode.OK, parityResponse.StatusCode);
var parity = await client.GetFromJsonAsync<ParityResponse>($"/api/v1/packs/{created.PackId}/parity", ct);
Assert.NotNull(parity);
Assert.Equal("ready", parity!.Status);
var newSignature = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(created.Digest));
var rotationResponse = await client.PostAsJsonAsync($"/api/v1/packs/{created.PackId}/signature", new RotateSignatureRequest { Signature = newSignature }, ct);
Assert.Equal(HttpStatusCode.OK, rotationResponse.StatusCode);
var rotated = await rotationResponse.Content.ReadFromJsonAsync<PackResponse>(cancellationToken: ct);
Assert.Equal(newSignature, rotated!.Signature);
var offlineSeed = await client.PostAsJsonAsync("/api/v1/export/offline-seed", new OfflineSeedRequest { TenantId = "t1", IncludeContent = true, IncludeProvenance = true }, ct);
Assert.Equal(HttpStatusCode.OK, offlineSeed.StatusCode);
var bytesZip = await offlineSeed.Content.ReadAsByteArrayAsync(ct);
using var archive = new ZipArchive(new MemoryStream(bytesZip));
Assert.NotNull(archive.GetEntry("packs.ndjson"));
Assert.NotNull(archive.GetEntry($"content/{created.PackId}.bin"));
}
}

View File

@@ -0,0 +1,48 @@
using System.Security.Cryptography;
using System.Text;
using StellaOps.PacksRegistry.Infrastructure.Verification;
namespace StellaOps.PacksRegistry.Tests;
public sealed class RsaSignatureVerifierTests
{
[Fact]
public async Task Verify_succeeds_when_signature_matches_digest()
{
var ct = TestContext.Current.CancellationToken;
using var rsa = RSA.Create(2048);
var publicPem = ExportPublicPem(rsa);
const string digest = "sha256:deadbeef";
var sig = Convert.ToBase64String(rsa.SignData(Encoding.UTF8.GetBytes(digest), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
var verifier = new RsaSignatureVerifier(publicPem);
var ok = await verifier.VerifyAsync(Array.Empty<byte>(), digest, sig, ct);
Assert.True(ok);
}
[Fact]
public async Task Verify_fails_on_invalid_signature()
{
var ct = TestContext.Current.CancellationToken;
using var rsa = RSA.Create(2048);
var publicPem = ExportPublicPem(rsa);
const string digest = "sha256:deadbeef";
var sig = Convert.ToBase64String(Encoding.UTF8.GetBytes("bogus"));
var verifier = new RsaSignatureVerifier(publicPem);
var ok = await verifier.VerifyAsync(Array.Empty<byte>(), digest, sig, ct);
Assert.False(ok);
}
private static string ExportPublicPem(RSA rsa)
{
var builder = new StringBuilder();
builder.AppendLine("-----BEGIN PUBLIC KEY-----");
builder.AppendLine(Convert.ToBase64String(rsa.ExportSubjectPublicKeyInfo(), Base64FormattingOptions.InsertLineBreaks));
builder.AppendLine("-----END PUBLIC KEY-----");
return builder.ToString();
}
}

View File

@@ -1,135 +1,34 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
<PackageReference Include="xunit.v3" Version="3.0.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3"/>
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest"/>
</ItemGroup>
<ItemGroup>
<Using Include="Xunit"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.PacksRegistry.Core\StellaOps.PacksRegistry.Core.csproj"/>
<ProjectReference Include="..\StellaOps.PacksRegistry.Infrastructure\StellaOps.PacksRegistry.Infrastructure.csproj"/>
</ItemGroup>
</Project>
<?xml version="1.0"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="xunit.v3" Version="3.0.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.PacksRegistry.Core\StellaOps.PacksRegistry.Core.csproj" />
<ProjectReference Include="..\StellaOps.PacksRegistry.Infrastructure\StellaOps.PacksRegistry.Infrastructure.csproj" />
<ProjectReference Include="..\StellaOps.PacksRegistry.WebService\StellaOps.PacksRegistry.WebService.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,10 +0,0 @@
namespace StellaOps.PacksRegistry.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}

View File

@@ -0,0 +1,9 @@
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.WebService.Contracts;
public sealed record AttestationResponse(string PackId, string Type, string Digest, DateTimeOffset CreatedAtUtc, string? Notes)
{
public static AttestationResponse From(AttestationRecord record) => new(record.PackId, record.Type, record.Digest, record.CreatedAtUtc, record.Notes);
}

View File

@@ -0,0 +1,9 @@
namespace StellaOps.PacksRegistry.WebService.Contracts;
public sealed class AttestationUploadRequest
{
public string? Type { get; set; }
public string? Content { get; set; }
public string? Notes { get; set; }
}

View File

@@ -0,0 +1,9 @@
using StellaOps.PacksRegistry.Core.Services;
namespace StellaOps.PacksRegistry.WebService.Contracts;
public sealed record ComplianceSummaryResponse(int TotalPacks, int UnsignedPacks, int PromotedPacks, int DeprecatedPacks, int ParityReadyPacks)
{
public static ComplianceSummaryResponse From(ComplianceSummary summary) => new(summary.TotalPacks, summary.UnsignedPacks, summary.PromotedPacks, summary.DeprecatedPacks, summary.ParityReadyPacks);
}

View File

@@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
namespace StellaOps.PacksRegistry.WebService.Contracts;
public sealed record LifecycleRequest
{
[Required]
public string? State { get; init; }
public string? Notes { get; init; }
}

View File

@@ -0,0 +1,8 @@
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.WebService.Contracts;
public sealed record LifecycleResponse(string PackId, string TenantId, string State, string? Notes, DateTimeOffset UpdatedAtUtc)
{
public static LifecycleResponse From(LifecycleRecord record) => new(record.PackId, record.TenantId, record.State, record.Notes, record.UpdatedAtUtc);
}

View File

@@ -0,0 +1,10 @@
namespace StellaOps.PacksRegistry.WebService.Contracts;
public sealed class MirrorRequest
{
public string? Id { get; set; }
public string? Upstream { get; set; }
public bool Enabled { get; set; } = true;
public string? Notes { get; set; }
}

View File

@@ -0,0 +1,9 @@
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.WebService.Contracts;
public sealed record MirrorResponse(string Id, string TenantId, string Upstream, bool Enabled, string Status, DateTimeOffset UpdatedAtUtc, DateTimeOffset? LastSuccessfulSyncUtc, string? Notes)
{
public static MirrorResponse From(MirrorSourceRecord record) => new(record.Id, record.TenantId, record.UpstreamUri.ToString(), record.Enabled, record.Status, record.UpdatedAtUtc, record.LastSuccessfulSyncUtc, record.Notes);
}

View File

@@ -0,0 +1,8 @@
namespace StellaOps.PacksRegistry.WebService.Contracts;
public sealed class MirrorSyncRequest
{
public string? Status { get; set; }
public string? Notes { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace StellaOps.PacksRegistry.WebService.Contracts;
public sealed class OfflineSeedRequest
{
public string? TenantId { get; set; }
public bool IncludeContent { get; set; }
public bool IncludeProvenance { get; set; }
}

View File

@@ -0,0 +1,11 @@
namespace StellaOps.PacksRegistry.WebService.Contracts;
public sealed record PackManifestResponse(
string PackId,
string TenantId,
string Digest,
long ContentLength,
string? ProvenanceDigest,
long? ProvenanceLength,
DateTimeOffset CreatedAtUtc,
IReadOnlyDictionary<string, string>? Metadata);

View File

@@ -0,0 +1,18 @@
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.WebService.Contracts;
public sealed record PackResponse(
string PackId,
string Name,
string Version,
string TenantId,
string Digest,
string? Signature,
string? ProvenanceUri,
DateTimeOffset CreatedAtUtc,
IReadOnlyDictionary<string, string>? Metadata)
{
public static PackResponse From(PackRecord record) =>
new(record.PackId, record.Name, record.Version, record.TenantId, record.Digest, record.Signature, record.ProvenanceUri, record.CreatedAtUtc, record.Metadata);
}

View File

@@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace StellaOps.PacksRegistry.WebService.Contracts;
public sealed record PackUploadRequest
{
[Required]
public string? Name { get; init; }
[Required]
public string? Version { get; init; }
public string? TenantId { get; init; }
[Required]
public string? Content { get; init; } // base64 encoded
public string? Signature { get; init; }
public string? ProvenanceUri { get; init; }
/// <summary>
/// Optional provenance manifest content (base64). Stored and downloadable when present.
/// </summary>
public string? ProvenanceContent { get; init; }
[JsonPropertyName("metadata")]
public Dictionary<string, string>? Metadata { get; init; }
}

View File

@@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
namespace StellaOps.PacksRegistry.WebService.Contracts;
public sealed record ParityRequest
{
[Required]
public string? Status { get; init; }
public string? Notes { get; init; }
}

View File

@@ -0,0 +1,8 @@
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.WebService.Contracts;
public sealed record ParityResponse(string PackId, string TenantId, string Status, string? Notes, DateTimeOffset UpdatedAtUtc)
{
public static ParityResponse From(ParityRecord record) => new(record.PackId, record.TenantId, record.Status, record.Notes, record.UpdatedAtUtc);
}

View File

@@ -0,0 +1,12 @@
namespace StellaOps.PacksRegistry.WebService.Contracts;
public sealed class RotateSignatureRequest
{
public string? Signature { get; set; }
/// <summary>
/// Optional PEM-encoded public key to validate the new signature; falls back to configured verifier if not provided.
/// </summary>
public string? PublicKeyPem { get; set; }
}

View File

@@ -0,0 +1,50 @@
{
"openapi": "3.1.0",
"info": {
"title": "StellaOps Packs Registry",
"version": "0.1.0"
},
"paths": {
"/api/v1/packs/{packId}/manifest": {
"get": {
"summary": "Fetch pack manifest",
"parameters": [
{ "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } },
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
{ "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } }
],
"responses": {
"200": {
"description": "Manifest",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/PackManifest" }
}
}
},
"401": { "description": "Unauthorized" },
"403": { "description": "Forbidden" },
"404": { "description": "Not Found" }
}
}
}
},
"components": {
"schemas": {
"PackManifest": {
"type": "object",
"properties": {
"packId": { "type": "string" },
"tenantId": { "type": "string" },
"digest": { "type": "string" },
"contentLength": { "type": "integer", "format": "int64" },
"provenanceDigest": { "type": "string", "nullable": true },
"provenanceLength": { "type": "integer", "format": "int64", "nullable": true },
"createdAtUtc": { "type": "string", "format": "date-time" },
"metadata": { "type": "object", "additionalProperties": { "type": "string" }, "nullable": true }
},
"required": ["packId", "tenantId", "digest", "contentLength", "createdAtUtc"]
}
}
}
}

View File

@@ -0,0 +1,290 @@
{
"openapi": "3.1.0",
"info": {
"title": "StellaOps Packs Registry",
"version": "0.3.0"
},
"paths": {
"/api/v1/packs": {
"get": {
"summary": "List packs",
"parameters": [
{ "name": "tenant", "in": "query", "schema": { "type": "string" }, "description": "Filter to tenant; required when allowlists are configured." },
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
{ "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "OK" }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" } }
},
"post": {
"summary": "Upload pack",
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PackUpload" } } } },
"parameters": [
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
{ "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } }
],
"responses": { "201": { "description": "Created" }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" } }
}
},
"/api/v1/packs/{packId}": {
"get": {
"summary": "Get pack",
"parameters": [
{ "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } },
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
{ "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "OK" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } }
}
},
"/api/v1/packs/{packId}/content": {
"get": {
"summary": "Download pack content",
"parameters": [
{ "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } },
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
{ "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "Content" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } }
}
},
"/api/v1/packs/{packId}/provenance": {
"get": {
"summary": "Download provenance",
"parameters": [
{ "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } },
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
{ "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "Provenance" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } }
}
},
"/api/v1/packs/{packId}/manifest": {
"get": {
"summary": "Get manifest",
"parameters": [
{ "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } },
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
{ "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "Manifest" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } }
}
},
"/api/v1/packs/{packId}/parity": {
"get": {
"summary": "Get parity status",
"parameters": [
{ "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } },
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
{ "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "Parity" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } }
},
"post": {
"summary": "Set parity status",
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ParityRequest" } } } },
"parameters": [
{ "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } },
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
{ "name": "X-StellaOps-Tenant", "in": "header", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "Updated" }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } }
}
},
"/api/v1/packs/{packId}/lifecycle": {
"get": {
"summary": "Get lifecycle state",
"parameters": [
{ "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } },
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
{ "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "Lifecycle" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } }
},
"post": {
"summary": "Set lifecycle state",
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/LifecycleRequest" } } } },
"parameters": [
{ "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } },
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
{ "name": "X-StellaOps-Tenant", "in": "header", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "Updated" }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } }
}
},
"/api/v1/packs/{packId}/signature": {
"post": {
"summary": "Rotate pack signature",
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RotateSignatureRequest" } } } },
"parameters": [
{ "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } },
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
{ "name": "X-StellaOps-Tenant", "in": "header", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "Rotated" }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } }
}
},
"/api/v1/packs/{packId}/attestations": {
"get": {
"summary": "List pack attestations",
"parameters": [
{ "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } },
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
{ "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "Attestations" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } }
},
"post": {
"summary": "Upload attestation",
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AttestationUpload" } } } },
"parameters": [
{ "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } },
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
{ "name": "X-StellaOps-Tenant", "in": "header", "required": true, "schema": { "type": "string" } }
],
"responses": { "201": { "description": "Created" }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } }
}
},
"/api/v1/packs/{packId}/attestations/{type}": {
"get": {
"summary": "Download attestation",
"parameters": [
{ "name": "packId", "in": "path", "required": true, "schema": { "type": "string" } },
{ "name": "type", "in": "path", "required": true, "schema": { "type": "string" } },
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
{ "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "Attestation" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } }
}
},
"/api/v1/export/offline-seed": {
"post": {
"summary": "Export offline seed archive",
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OfflineSeedRequest" } } } },
"parameters": [
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
{ "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "Archive" }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" } }
}
},
"/api/v1/mirrors": {
"get": {
"summary": "List mirror sources",
"parameters": [
{ "name": "tenant", "in": "query", "schema": { "type": "string" } },
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
{ "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "OK" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" } }
},
"post": {
"summary": "Register or update mirror source",
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MirrorRequest" } } } },
"parameters": [
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
{ "name": "X-StellaOps-Tenant", "in": "header", "required": true, "schema": { "type": "string" } }
],
"responses": { "201": { "description": "Created" }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" } }
}
},
"/api/v1/mirrors/{id}/sync": {
"post": {
"summary": "Mark mirror sync status",
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MirrorSyncRequest" } } } },
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } },
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
{ "name": "X-StellaOps-Tenant", "in": "header", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "Updated" }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" } }
}
},
"/api/v1/compliance/summary": {
"get": {
"summary": "Compliance summary",
"parameters": [
{ "name": "tenant", "in": "query", "schema": { "type": "string" } },
{ "name": "X-API-Key", "in": "header", "required": false, "schema": { "type": "string" } },
{ "name": "X-StellaOps-Tenant", "in": "header", "required": false, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "Summary" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" } }
}
}
},
"components": {
"schemas": {
"PackUpload": {
"type": "object",
"properties": {
"name": { "type": "string" },
"version": { "type": "string" },
"tenantId": { "type": "string" },
"content": { "type": "string", "format": "byte" },
"signature": { "type": "string" },
"provenanceUri": { "type": "string" },
"provenanceContent": { "type": "string", "format": "byte" },
"metadata": { "type": "object", "additionalProperties": { "type": "string" } }
},
"required": ["name", "version", "content"]
},
"ParityRequest": {
"type": "object",
"properties": {
"status": { "type": "string" },
"notes": { "type": "string" }
},
"required": ["status"]
},
"LifecycleRequest": {
"type": "object",
"properties": {
"state": { "type": "string", "enum": ["promoted", "deprecated", "draft"] },
"notes": { "type": "string" }
},
"required": ["state"]
},
"RotateSignatureRequest": {
"type": "object",
"properties": {
"signature": { "type": "string" },
"publicKeyPem": { "type": "string" }
},
"required": ["signature"]
},
"OfflineSeedRequest": {
"type": "object",
"properties": {
"tenantId": { "type": "string" },
"includeContent": { "type": "boolean" },
"includeProvenance": { "type": "boolean" }
}
},
"AttestationUpload": {
"type": "object",
"properties": {
"type": { "type": "string" },
"content": { "type": "string", "format": "byte" },
"notes": { "type": "string" }
},
"required": ["type", "content"]
},
"MirrorRequest": {
"type": "object",
"properties": {
"id": { "type": "string" },
"upstream": { "type": "string", "format": "uri" },
"enabled": { "type": "boolean" },
"notes": { "type": "string" }
},
"required": ["id", "upstream"]
},
"MirrorSyncRequest": {
"type": "object",
"properties": {
"status": { "type": "string" },
"notes": { "type": "string" }
}
}
}
}
}

View File

@@ -0,0 +1,8 @@
namespace StellaOps.PacksRegistry.WebService.Options;
public sealed class AuthOptions
{
public string? ApiKey { get; set; }
public string[] AllowedTenants { get; set; } = Array.Empty<string>();
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.PacksRegistry.WebService.Options;
public sealed class VerificationOptions
{
public string? PublicKeyPem { get; set; }
}

View File

@@ -1,41 +1,770 @@
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
var summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
app.MapGet("/weatherforecast", () =>
{
var forecast = Enumerable.Range(1, 5).Select(index =>
new WeatherForecast
(
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
Random.Shared.Next(-20, 55),
summaries[Random.Shared.Next(summaries.Length)]
))
.ToArray();
return forecast;
})
.WithName("GetWeatherForecast");
app.Run();
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
{
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}
using System.Text.Json.Serialization;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
using StellaOps.PacksRegistry.Core.Services;
using StellaOps.PacksRegistry.Infrastructure.FileSystem;
using StellaOps.PacksRegistry.Infrastructure.InMemory;
using StellaOps.PacksRegistry.Infrastructure.Verification;
using StellaOps.PacksRegistry.Infrastructure.Mongo;
using StellaOps.PacksRegistry.Infrastructure.Options;
using StellaOps.PacksRegistry.WebService;
using StellaOps.PacksRegistry.WebService.Contracts;
using StellaOps.PacksRegistry.WebService.Options;
using Microsoft.Extensions.FileProviders;
using MongoDB.Driver;
var builder = WebApplication.CreateBuilder(args);
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
options.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
});
builder.Services.AddOpenApi();
var dataDir = builder.Configuration.GetValue<string>("PacksRegistry:DataDir");
var mongoOptions = builder.Configuration.GetSection("PacksRegistry:Mongo").Get<MongoOptions>() ?? new MongoOptions();
mongoOptions.ConnectionString ??= builder.Configuration.GetConnectionString("packs-registry");
if (!string.IsNullOrWhiteSpace(mongoOptions.ConnectionString))
{
builder.Services.AddSingleton(mongoOptions);
builder.Services.AddSingleton<IMongoClient>(_ => new MongoClient(mongoOptions.ConnectionString));
builder.Services.AddSingleton(sp => sp.GetRequiredService<IMongoClient>().GetDatabase(mongoOptions.Database));
builder.Services.AddSingleton<IPackRepository, MongoPackRepository>();
builder.Services.AddSingleton<IParityRepository, MongoParityRepository>();
builder.Services.AddSingleton<ILifecycleRepository, MongoLifecycleRepository>();
builder.Services.AddSingleton<IAuditRepository, MongoAuditRepository>();
builder.Services.AddSingleton<IAttestationRepository, MongoAttestationRepository>();
builder.Services.AddSingleton<IMirrorRepository, MongoMirrorRepository>();
builder.Services.AddHostedService<PacksMongoInitializer>();
}
else
{
builder.Services.AddSingleton<IPackRepository>(_ => new FilePackRepository(dataDir ?? "data/packs"));
builder.Services.AddSingleton<IParityRepository>(_ => new FileParityRepository(dataDir ?? "data/packs"));
builder.Services.AddSingleton<ILifecycleRepository>(_ => new FileLifecycleRepository(dataDir ?? "data/packs"));
builder.Services.AddSingleton<IAuditRepository>(_ => new FileAuditRepository(dataDir ?? "data/packs"));
builder.Services.AddSingleton<IAttestationRepository>(_ => new FileAttestationRepository(dataDir ?? "data/packs"));
builder.Services.AddSingleton<IMirrorRepository>(_ => new FileMirrorRepository(dataDir ?? "data/packs"));
}
var verificationSection = builder.Configuration.GetSection("PacksRegistry:Verification");
builder.Services.Configure<VerificationOptions>(verificationSection);
var publicKeyPem = verificationSection.GetValue<string>("PublicKeyPem");
if (!string.IsNullOrWhiteSpace(publicKeyPem))
{
builder.Services.AddSingleton<IPackSignatureVerifier>(_ => new RsaSignatureVerifier(publicKeyPem));
}
else
{
builder.Services.AddSingleton<IPackSignatureVerifier, SimpleSignatureVerifier>();
}
var authOptions = builder.Configuration.GetSection("PacksRegistry:Auth").Get<AuthOptions>() ?? new AuthOptions();
builder.Services.AddSingleton(authOptions);
var policyOptions = builder.Configuration.GetSection("PacksRegistry:Policy").Get<PackPolicyOptions>() ?? new PackPolicyOptions();
builder.Services.AddSingleton(policyOptions);
builder.Services.AddSingleton<PackService>();
builder.Services.AddSingleton<ParityService>();
builder.Services.AddSingleton<LifecycleService>();
builder.Services.AddSingleton<AttestationService>();
builder.Services.AddSingleton<MirrorService>();
builder.Services.AddSingleton<ComplianceService>();
builder.Services.AddSingleton<ExportService>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddHealthChecks();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.MapHealthChecks("/healthz");
// Serve static OpenAPI stubs for packs APIs (until unified spec is generated)
var openApiDir = Path.Combine(app.Environment.ContentRootPath, "OpenApi");
if (Directory.Exists(openApiDir))
{
var provider = new PhysicalFileProvider(openApiDir);
app.MapGet("/openapi/packs.json", () =>
{
var file = provider.GetFileInfo("packs.openapi.json");
return file.Exists
? Results.File(file.CreateReadStream(), "application/json")
: Results.NotFound();
})
.ExcludeFromDescription();
app.MapGet("/openapi/pack-manifest.json", () =>
{
var file = provider.GetFileInfo("pack-manifest.openapi.json");
return file.Exists
? Results.File(file.CreateReadStream(), "application/json")
: Results.NotFound();
})
.ExcludeFromDescription();
}
app.MapPost("/api/v1/packs", async (PackUploadRequest request, PackService service, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
var tenant = !string.IsNullOrWhiteSpace(request.TenantId) ? request.TenantId : tenantHeader;
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant_missing", message = "X-StellaOps-Tenant header or tenantId is required." });
}
if (!IsTenantAllowed(tenant, auth, out var tenantResult))
{
return tenantResult;
}
if (request.Content == null || request.Content.Length == 0)
{
return Results.BadRequest(new { error = "content_missing", message = "Content (base64) is required." });
}
try
{
var contentBytes = Convert.FromBase64String(request.Content);
byte[]? provenanceBytes = null;
if (!string.IsNullOrWhiteSpace(request.ProvenanceContent))
{
provenanceBytes = Convert.FromBase64String(request.ProvenanceContent);
}
var record = await service.UploadAsync(
name: request.Name ?? string.Empty,
version: request.Version ?? string.Empty,
tenantId: tenant,
content: contentBytes,
signature: request.Signature,
provenanceUri: request.ProvenanceUri,
provenanceContent: provenanceBytes,
metadata: request.Metadata,
cancellationToken: cancellationToken);
return Results.Created($"/api/v1/packs/{record.PackId}", PackResponse.From(record));
}
catch (FormatException)
{
return Results.BadRequest(new { error = "content_base64_invalid", message = "Content must be valid base64." });
}
catch (Exception ex)
{
return Results.BadRequest(new { error = "upload_failed", message = ex.Message });
}
})
.Produces<PackResponse>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized);
app.MapGet("/api/v1/packs", async (string? tenant, PackService service, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
var effectiveTenant = !string.IsNullOrWhiteSpace(tenant) ? tenant : tenantHeader;
if (auth.AllowedTenants is { Length: > 0 } && string.IsNullOrWhiteSpace(effectiveTenant))
{
return Results.BadRequest(new { error = "tenant_missing", message = "tenant query parameter or X-StellaOps-Tenant header is required when tenant allowlists are configured." });
}
if (!string.IsNullOrWhiteSpace(effectiveTenant) && !IsTenantAllowed(effectiveTenant, auth, out var tenantResult))
{
return tenantResult;
}
var packs = await service.ListAsync(effectiveTenant, cancellationToken).ConfigureAwait(false);
return Results.Ok(packs.Select(PackResponse.From));
})
.Produces<IEnumerable<PackResponse>>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized);
app.MapGet("/api/v1/packs/{packId}", async (string packId, PackService service, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
var record = await service.GetAsync(packId, cancellationToken).ConfigureAwait(false);
if (record is null)
{
return Results.NotFound();
}
if (!IsTenantAllowed(record.TenantId, auth, out var tenantResult) ||
(!string.IsNullOrWhiteSpace(tenantHeader) && !string.Equals(record.TenantId, tenantHeader, StringComparison.OrdinalIgnoreCase)))
{
return tenantResult ?? Results.Forbid();
}
return Results.Ok(PackResponse.From(record));
})
.Produces<PackResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
app.MapGet("/api/v1/packs/{packId}/content", async (string packId, PackService service, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
var record = await service.GetAsync(packId, cancellationToken).ConfigureAwait(false);
if (record is null)
{
return Results.NotFound();
}
if (!IsTenantAllowed(record.TenantId, auth, out var tenantResult) ||
(!string.IsNullOrWhiteSpace(tenantHeader) && !string.Equals(record.TenantId, tenantHeader, StringComparison.OrdinalIgnoreCase)))
{
return tenantResult ?? Results.Forbid();
}
var content = await service.GetContentAsync(packId, cancellationToken).ConfigureAwait(false);
if (content is null)
{
return Results.NotFound();
}
context.Response.Headers["X-Content-Digest"] = record.Digest;
return Results.File(content, "application/octet-stream", fileDownloadName: packId + ".bin");
})
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
app.MapGet("/api/v1/packs/{packId}/provenance", async (string packId, PackService service, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
var record = await service.GetAsync(packId, cancellationToken).ConfigureAwait(false);
if (record is null)
{
return Results.NotFound();
}
if (!IsTenantAllowed(record.TenantId, auth, out var tenantResult) ||
(!string.IsNullOrWhiteSpace(tenantHeader) && !string.Equals(record.TenantId, tenantHeader, StringComparison.OrdinalIgnoreCase)))
{
return tenantResult ?? Results.Forbid();
}
var content = await service.GetProvenanceAsync(packId, cancellationToken).ConfigureAwait(false);
if (content is null)
{
return Results.NotFound();
}
if (!string.IsNullOrWhiteSpace(record.ProvenanceDigest))
{
context.Response.Headers["X-Provenance-Digest"] = record.ProvenanceDigest;
}
return Results.File(content, "application/json", fileDownloadName: packId + "-provenance.json");
})
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
app.MapGet("/api/v1/packs/{packId}/manifest", async (string packId, PackService service, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
var record = await service.GetAsync(packId, cancellationToken).ConfigureAwait(false);
if (record is null)
{
return Results.NotFound();
}
if (!IsTenantAllowed(record.TenantId, auth, out var tenantResult) ||
(!string.IsNullOrWhiteSpace(tenantHeader) && !string.Equals(record.TenantId, tenantHeader, StringComparison.OrdinalIgnoreCase)))
{
return tenantResult ?? Results.Forbid();
}
var content = await service.GetContentAsync(packId, cancellationToken).ConfigureAwait(false);
var provenance = await service.GetProvenanceAsync(packId, cancellationToken).ConfigureAwait(false);
var manifest = new PackManifestResponse(
record.PackId,
record.TenantId,
record.Digest,
content?.LongLength ?? 0,
record.ProvenanceDigest,
provenance?.LongLength,
record.CreatedAtUtc,
record.Metadata);
return Results.Ok(manifest);
})
.Produces<PackManifestResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
app.MapPost("/api/v1/packs/{packId}/signature", async (string packId, RotateSignatureRequest request, PackService service, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantHeader))
{
return Results.BadRequest(new { error = "tenant_missing", message = "X-StellaOps-Tenant header is required." });
}
if (!IsTenantAllowed(tenantHeader, auth, out var tenantResult))
{
return tenantResult;
}
if (string.IsNullOrWhiteSpace(request.Signature))
{
return Results.BadRequest(new { error = "signature_missing", message = "signature is required." });
}
IPackSignatureVerifier? overrideVerifier = null;
if (!string.IsNullOrWhiteSpace(request.PublicKeyPem))
{
overrideVerifier = new RsaSignatureVerifier(request.PublicKeyPem!);
}
try
{
var updated = await service.RotateSignatureAsync(packId, tenantHeader, request.Signature!, overrideVerifier, cancellationToken).ConfigureAwait(false);
return Results.Ok(PackResponse.From(updated));
}
catch (Exception ex)
{
return Results.BadRequest(new { error = "signature_rotation_failed", message = ex.Message });
}
})
.Produces<PackResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
app.MapPost("/api/v1/packs/{packId}/attestations", async (string packId, AttestationUploadRequest request, AttestationService attestationService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantHeader))
{
return Results.BadRequest(new { error = "tenant_missing", message = "X-StellaOps-Tenant header is required." });
}
if (!IsTenantAllowed(tenantHeader, auth, out var tenantResult))
{
return tenantResult;
}
if (string.IsNullOrWhiteSpace(request.Type) || string.IsNullOrWhiteSpace(request.Content))
{
return Results.BadRequest(new { error = "attestation_missing", message = "type and content are required." });
}
try
{
var bytes = Convert.FromBase64String(request.Content);
var record = await attestationService.UploadAsync(packId, tenantHeader, request.Type!, bytes, request.Notes, cancellationToken).ConfigureAwait(false);
return Results.Created($"/api/v1/packs/{packId}/attestations/{record.Type}", AttestationResponse.From(record));
}
catch (Exception ex)
{
return Results.BadRequest(new { error = "attestation_failed", message = ex.Message });
}
})
.Produces<AttestationResponse>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
app.MapGet("/api/v1/packs/{packId}/attestations", async (string packId, AttestationService attestationService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
var records = await attestationService.ListAsync(packId, cancellationToken).ConfigureAwait(false);
if (records.Count == 0)
{
return Results.NotFound();
}
if (!string.IsNullOrWhiteSpace(tenantHeader) && !records.All(r => string.Equals(r.TenantId, tenantHeader, StringComparison.OrdinalIgnoreCase)))
{
return Results.Forbid();
}
return Results.Ok(records.Select(AttestationResponse.From));
})
.Produces<IEnumerable<AttestationResponse>>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
app.MapGet("/api/v1/packs/{packId}/attestations/{type}", async (string packId, string type, AttestationService attestationService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
var record = await attestationService.GetAsync(packId, type, cancellationToken).ConfigureAwait(false);
if (record is null)
{
return Results.NotFound();
}
if (!string.IsNullOrWhiteSpace(tenantHeader) && !string.Equals(record.TenantId, tenantHeader, StringComparison.OrdinalIgnoreCase))
{
return Results.Forbid();
}
var content = await attestationService.GetContentAsync(packId, type, cancellationToken).ConfigureAwait(false);
if (content is null)
{
return Results.NotFound();
}
context.Response.Headers["X-Attestation-Digest"] = record.Digest;
return Results.File(content, "application/octet-stream", fileDownloadName: $"{packId}-{type}-attestation.bin");
})
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
app.MapGet("/api/v1/packs/{packId}/parity", async (string packId, ParityService parityService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
var parity = await parityService.GetAsync(packId, cancellationToken).ConfigureAwait(false);
if (parity is null)
{
return Results.NotFound();
}
if (!IsTenantAllowed(parity.TenantId, auth, out var tenantResult) ||
(!string.IsNullOrWhiteSpace(tenantHeader) && !string.Equals(parity.TenantId, tenantHeader, StringComparison.OrdinalIgnoreCase)))
{
return tenantResult ?? Results.Forbid();
}
return Results.Ok(ParityResponse.From(parity));
})
.Produces<ParityResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
app.MapGet("/api/v1/packs/{packId}/lifecycle", async (string packId, LifecycleService lifecycleService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
var record = await lifecycleService.GetAsync(packId, cancellationToken).ConfigureAwait(false);
if (record is null)
{
return Results.NotFound();
}
if (!IsTenantAllowed(record.TenantId, auth, out var tenantResult) ||
(!string.IsNullOrWhiteSpace(tenantHeader) && !string.Equals(record.TenantId, tenantHeader, StringComparison.OrdinalIgnoreCase)))
{
return tenantResult ?? Results.Forbid();
}
return Results.Ok(LifecycleResponse.From(record));
})
.Produces<LifecycleResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
app.MapPost("/api/v1/packs/{packId}/lifecycle", async (string packId, LifecycleRequest request, LifecycleService lifecycleService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
var tenant = !string.IsNullOrWhiteSpace(tenantHeader) ? tenantHeader : null;
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant_missing", message = "X-StellaOps-Tenant header is required." });
}
if (!IsTenantAllowed(tenant, auth, out var tenantResult))
{
return tenantResult;
}
if (string.IsNullOrWhiteSpace(request.State))
{
return Results.BadRequest(new { error = "state_missing", message = "state is required." });
}
try
{
var record = await lifecycleService.SetStateAsync(packId, tenant, request.State!, request.Notes, cancellationToken).ConfigureAwait(false);
return Results.Ok(LifecycleResponse.From(record));
}
catch (Exception ex)
{
return Results.BadRequest(new { error = "lifecycle_failed", message = ex.Message });
}
})
.Produces<LifecycleResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
app.MapPost("/api/v1/packs/{packId}/parity", async (string packId, ParityRequest request, ParityService parityService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
var tenant = !string.IsNullOrWhiteSpace(tenantHeader) ? tenantHeader : null;
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant_missing", message = "X-StellaOps-Tenant header is required." });
}
if (!IsTenantAllowed(tenant, auth, out var tenantResult))
{
return tenantResult;
}
if (string.IsNullOrWhiteSpace(request.Status))
{
return Results.BadRequest(new { error = "status_missing", message = "status is required." });
}
try
{
var record = await parityService.SetStatusAsync(packId, tenant, request.Status!, request.Notes, cancellationToken).ConfigureAwait(false);
return Results.Ok(ParityResponse.From(record));
}
catch (Exception ex)
{
return Results.BadRequest(new { error = "parity_failed", message = ex.Message });
}
})
.Produces<ParityResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
app.MapPost("/api/v1/export/offline-seed", async (OfflineSeedRequest request, ExportService exportService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
var tenant = !string.IsNullOrWhiteSpace(request.TenantId) ? request.TenantId : tenantHeader;
if (auth.AllowedTenants is { Length: > 0 } && string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant_missing", message = "tenantId or X-StellaOps-Tenant header is required when tenant allowlists are configured." });
}
if (!string.IsNullOrWhiteSpace(tenant) && !IsTenantAllowed(tenant, auth, out var tenantResult))
{
return tenantResult;
}
var archive = await exportService.ExportOfflineSeedAsync(tenant, request.IncludeContent, request.IncludeProvenance, cancellationToken).ConfigureAwait(false);
return Results.File(archive, "application/zip", fileDownloadName: "packs-offline-seed.zip");
})
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden);
app.MapPost("/api/v1/mirrors", async (MirrorRequest request, MirrorService mirrorService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantHeader))
{
return Results.BadRequest(new { error = "tenant_missing", message = "X-StellaOps-Tenant header is required." });
}
if (!IsTenantAllowed(tenantHeader, auth, out var tenantResult))
{
return tenantResult;
}
if (string.IsNullOrWhiteSpace(request.Id) || string.IsNullOrWhiteSpace(request.Upstream))
{
return Results.BadRequest(new { error = "mirror_missing", message = "id and upstream are required." });
}
var record = await mirrorService.UpsertAsync(request.Id!, tenantHeader, new Uri(request.Upstream!), request.Enabled, request.Notes, cancellationToken).ConfigureAwait(false);
return Results.Created($"/api/v1/mirrors/{record.Id}", MirrorResponse.From(record));
})
.Produces<MirrorResponse>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden);
app.MapGet("/api/v1/mirrors", async (string? tenant, MirrorService mirrorService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
var effectiveTenant = !string.IsNullOrWhiteSpace(tenant) ? tenant : tenantHeader;
if (!string.IsNullOrWhiteSpace(effectiveTenant) && !IsTenantAllowed(effectiveTenant, auth, out var tenantResult))
{
return tenantResult;
}
var mirrors = await mirrorService.ListAsync(effectiveTenant, cancellationToken).ConfigureAwait(false);
return Results.Ok(mirrors.Select(MirrorResponse.From));
})
.Produces<IEnumerable<MirrorResponse>>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden);
app.MapPost("/api/v1/mirrors/{id}/sync", async (string id, MirrorSyncRequest request, MirrorService mirrorService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantHeader))
{
return Results.BadRequest(new { error = "tenant_missing", message = "X-StellaOps-Tenant header is required." });
}
var updated = await mirrorService.MarkSyncAsync(id, tenantHeader, request.Status ?? "unknown", request.Notes, cancellationToken).ConfigureAwait(false);
if (updated is null)
{
return Results.NotFound();
}
return Results.Ok(MirrorResponse.From(updated));
})
.Produces<MirrorResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
app.MapGet("/api/v1/compliance/summary", async (string? tenant, ComplianceService complianceService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
var effectiveTenant = !string.IsNullOrWhiteSpace(tenant) ? tenant : tenantHeader;
if (!string.IsNullOrWhiteSpace(effectiveTenant) && !IsTenantAllowed(effectiveTenant, auth, out var tenantResult))
{
return tenantResult;
}
var summary = await complianceService.SummarizeAsync(effectiveTenant, cancellationToken).ConfigureAwait(false);
return Results.Ok(summary);
})
.Produces<ComplianceSummary>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden);
app.Run();
static bool IsAuthorized(HttpContext context, AuthOptions auth, out IResult result)
{
result = Results.Empty;
if (string.IsNullOrWhiteSpace(auth.ApiKey))
{
return true; // auth disabled
}
var provided = context.Request.Headers["X-API-Key"].ToString();
if (string.Equals(provided, auth.ApiKey, StringComparison.Ordinal))
{
return true;
}
result = Results.Unauthorized();
return false;
}
static bool IsTenantAllowed(string tenant, AuthOptions auth, out IResult? result)
{
result = null;
if (auth.AllowedTenants is { Length: > 0 } && !auth.AllowedTenants.Any(t => string.Equals(t, tenant, StringComparison.OrdinalIgnoreCase)))
{
result = Results.Forbid();
return false;
}
return true;
}
public partial class Program;

View File

@@ -1,23 +0,0 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5151",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7136;http://localhost:5151",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -1,8 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -1,9 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}