up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-11 08:20:15 +02:00
parent b8b493913a
commit ce1f282ce0
65 changed files with 5481 additions and 1803 deletions

View File

@@ -1,8 +1,6 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StellaOps.AirGap.Controller.Options;
using StellaOps.AirGap.Controller.Services;
using StellaOps.AirGap.Controller.Stores;
@@ -15,7 +13,6 @@ public static class AirGapControllerServiceCollectionExtensions
{
public static IServiceCollection AddAirGapController(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<AirGapControllerMongoOptions>(configuration.GetSection("AirGap:Mongo"));
services.Configure<AirGapStartupOptions>(configuration.GetSection("AirGap:Startup"));
services.AddSingleton<AirGapTelemetry>();
@@ -28,19 +25,9 @@ public static class AirGapControllerServiceCollectionExtensions
services.AddSingleton<IAirGapStateStore>(sp =>
{
var opts = sp.GetRequiredService<IOptions<AirGapControllerMongoOptions>>().Value;
var logger = sp.GetRequiredService<ILogger<MongoAirGapStateStore>>();
if (string.IsNullOrWhiteSpace(opts.ConnectionString))
{
logger.LogInformation("AirGap controller using in-memory state store (Mongo connection string not configured).");
return new InMemoryAirGapStateStore();
}
var mongoClient = new MongoClient(opts.ConnectionString);
var database = mongoClient.GetDatabase(string.IsNullOrWhiteSpace(opts.Database) ? "stellaops_airgap" : opts.Database);
var collection = MongoAirGapStateStore.EnsureCollection(database);
logger.LogInformation("AirGap controller using Mongo state store (db={Database}, collection={Collection}).", opts.Database, opts.Collection);
return new MongoAirGapStateStore(collection);
var logger = sp.GetRequiredService<ILogger<InMemoryAirGapStateStore>>();
logger.LogWarning("AirGap controller using in-memory state store; state resets on process restart.");
return new InMemoryAirGapStateStore();
});
services.AddHostedService<AirGapStartupDiagnosticsHostedService>();

View File

@@ -1,22 +0,0 @@
namespace StellaOps.AirGap.Controller.Options;
/// <summary>
/// Mongo configuration for the air-gap controller state store.
/// </summary>
public sealed class AirGapControllerMongoOptions
{
/// <summary>
/// Mongo connection string; when missing, the controller falls back to the in-memory store.
/// </summary>
public string? ConnectionString { get; set; }
/// <summary>
/// Database name. Default: "stellaops_airgap".
/// </summary>
public string Database { get; set; } = "stellaops_airgap";
/// <summary>
/// Collection name for state documents. Default: "airgap_state".
/// </summary>
public string Collection { get; set; } = "airgap_state";
}

View File

@@ -9,7 +9,4 @@
<ProjectReference Include="../StellaOps.AirGap.Time/StellaOps.AirGap.Time.csproj" />
<ProjectReference Include="../StellaOps.AirGap.Importer/StellaOps.AirGap.Importer.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
</ItemGroup>
</Project>

View File

@@ -1,17 +1,18 @@
using System.Collections.Concurrent;
using StellaOps.AirGap.Controller.Domain;
namespace StellaOps.AirGap.Controller.Stores;
public sealed class InMemoryAirGapStateStore : IAirGapStateStore
{
private readonly Dictionary<string, AirGapState> _states = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, AirGapState> _states = new(StringComparer.Ordinal);
public Task<AirGapState> GetAsync(string tenantId, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (_states.TryGetValue(tenantId, out var state))
{
return Task.FromResult(state);
return Task.FromResult(state with { });
}
return Task.FromResult(new AirGapState { TenantId = tenantId });
@@ -20,7 +21,7 @@ public sealed class InMemoryAirGapStateStore : IAirGapStateStore
public Task SetAsync(AirGapState state, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
_states[state.TenantId] = state;
_states[state.TenantId] = state with { };
return Task.CompletedTask;
}
}

View File

@@ -1,156 +0,0 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver;
using StellaOps.AirGap.Controller.Domain;
using StellaOps.AirGap.Time.Models;
namespace StellaOps.AirGap.Controller.Stores;
/// <summary>
/// Mongo-backed air-gap state store; single document per tenant.
/// </summary>
internal sealed class MongoAirGapStateStore : IAirGapStateStore
{
private readonly IMongoCollection<AirGapStateDocument> _collection;
public MongoAirGapStateStore(IMongoCollection<AirGapStateDocument> collection)
{
_collection = collection;
}
public async Task<AirGapState> GetAsync(string tenantId, CancellationToken cancellationToken = default)
{
var filter = Builders<AirGapStateDocument>.Filter.And(
Builders<AirGapStateDocument>.Filter.Eq(x => x.TenantId, tenantId),
Builders<AirGapStateDocument>.Filter.Eq(x => x.Id, AirGapState.SingletonId));
var doc = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return doc?.ToDomain() ?? new AirGapState { TenantId = tenantId };
}
public async Task SetAsync(AirGapState state, CancellationToken cancellationToken = default)
{
var doc = AirGapStateDocument.FromDomain(state);
var filter = Builders<AirGapStateDocument>.Filter.And(
Builders<AirGapStateDocument>.Filter.Eq(x => x.TenantId, state.TenantId),
Builders<AirGapStateDocument>.Filter.Eq(x => x.Id, AirGapState.SingletonId));
var options = new ReplaceOptions { IsUpsert = true };
await _collection.ReplaceOneAsync(filter, doc, options, cancellationToken).ConfigureAwait(false);
}
internal static IMongoCollection<AirGapStateDocument> EnsureCollection(IMongoDatabase database)
{
var collectionName = "airgap_state";
var exists = database.ListCollectionNames().ToList().Contains(collectionName);
if (!exists)
{
database.CreateCollection(collectionName);
}
var collection = database.GetCollection<AirGapStateDocument>(collectionName);
var keys = Builders<AirGapStateDocument>.IndexKeys
.Ascending(x => x.TenantId)
.Ascending(x => x.Id);
var model = new CreateIndexModel<AirGapStateDocument>(keys, new CreateIndexOptions { Unique = true });
collection.Indexes.CreateOne(model);
return collection;
}
}
internal sealed class AirGapStateDocument
{
[BsonId]
public string Id { get; init; } = AirGapState.SingletonId;
[BsonElement("tenant_id")]
public string TenantId { get; init; } = "default";
[BsonElement("sealed")]
public bool Sealed { get; init; }
= false;
[BsonElement("policy_hash")]
public string? PolicyHash { get; init; }
= null;
[BsonElement("time_anchor")]
public AirGapTimeAnchorDocument TimeAnchor { get; init; } = new();
[BsonElement("staleness_budget")]
public StalenessBudgetDocument StalenessBudget { get; init; } = new();
[BsonElement("last_transition_at")]
public DateTimeOffset LastTransitionAt { get; init; }
= DateTimeOffset.MinValue;
public AirGapState ToDomain() => new()
{
TenantId = TenantId,
Sealed = Sealed,
PolicyHash = PolicyHash,
TimeAnchor = TimeAnchor.ToDomain(),
StalenessBudget = StalenessBudget.ToDomain(),
LastTransitionAt = LastTransitionAt
};
public static AirGapStateDocument FromDomain(AirGapState state) => new()
{
TenantId = state.TenantId,
Sealed = state.Sealed,
PolicyHash = state.PolicyHash,
TimeAnchor = AirGapTimeAnchorDocument.FromDomain(state.TimeAnchor),
StalenessBudget = StalenessBudgetDocument.FromDomain(state.StalenessBudget),
LastTransitionAt = state.LastTransitionAt
};
}
internal sealed class AirGapTimeAnchorDocument
{
[BsonElement("anchor_time")]
public DateTimeOffset AnchorTime { get; init; }
= DateTimeOffset.MinValue;
[BsonElement("source")]
public string Source { get; init; } = "unknown";
[BsonElement("format")]
public string Format { get; init; } = "unknown";
[BsonElement("signature_fp")]
public string SignatureFingerprint { get; init; } = string.Empty;
[BsonElement("token_digest")]
public string TokenDigest { get; init; } = string.Empty;
public StellaOps.AirGap.Time.Models.TimeAnchor ToDomain() =>
new(AnchorTime, Source, Format, SignatureFingerprint, TokenDigest);
public static AirGapTimeAnchorDocument FromDomain(StellaOps.AirGap.Time.Models.TimeAnchor anchor) => new()
{
AnchorTime = anchor.AnchorTime,
Source = anchor.Source,
Format = anchor.Format,
SignatureFingerprint = anchor.SignatureFingerprint,
TokenDigest = anchor.TokenDigest
};
}
internal sealed class StalenessBudgetDocument
{
[BsonElement("warning_seconds")]
public long WarningSeconds { get; init; } = StalenessBudget.Default.WarningSeconds;
[BsonElement("breach_seconds")]
public long BreachSeconds { get; init; } = StalenessBudget.Default.BreachSeconds;
public StalenessBudget ToDomain() => new(WarningSeconds, BreachSeconds);
public static StalenessBudgetDocument FromDomain(StalenessBudget budget) => new()
{
WarningSeconds = budget.WarningSeconds,
BreachSeconds = budget.BreachSeconds
};
}

View File

@@ -15,3 +15,6 @@
| AIRGAP-IMP-56-002 | DONE | Root rotation policy (dual approval) + trust store; integrated into import validator; tests passing. | 2025-11-20 |
| AIRGAP-IMP-57-001 | DONE | In-memory RLS bundle catalog/items repos + schema doc; deterministic ordering and tests passing. | 2025-11-20 |
| AIRGAP-TIME-57-001 | DONE | Staleness calc, loader/fixtures, TimeStatusService/store, sealed validator, Ed25519 Roughtime + RFC3161 SignedCms verification, APIs + config sample delivered; awaiting final trust roots. | 2025-11-20 |
| MR-T10.6.1 | DONE | Removed Mongo-backed air-gap state store; controller now uses in-memory store only. | 2025-12-11 |
| MR-T10.6.2 | DONE | DI simplified to register in-memory air-gap state store (no Mongo options or client). | 2025-12-11 |
| MR-T10.6.3 | DONE | Converted controller tests to in-memory store; dropped Mongo2Go dependency. | 2025-12-11 |

View File

@@ -46,7 +46,7 @@
<PackageReference Include="Mongo2Go" Version="4.1.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="9.10.0" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.0.0" />
<Compile Include="$(ConcelierSharedTestsPath)AssemblyInfo.cs" Link="Shared\AssemblyInfo.cs" Condition="'$(ConcelierSharedTestsPath)' != ''" />
<Compile Include="$(ConcelierSharedTestsPath)MongoFixtureCollection.cs" Link="Shared\MongoFixtureCollection.cs" Condition="'$(ConcelierSharedTestsPath)' != ''" />
<ProjectReference Include="$(ConcelierTestingPath)StellaOps.Concelier.Testing.csproj" Condition="'$(ConcelierTestingPath)' != ''" />

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using FluentAssertions;
using System.Threading.Tasks;
using StellaOps.Provenance.Attestation;
using StellaOps.Cryptography;
using Xunit;
namespace StellaOps.Provenance.Attestation.Tests;
@@ -37,7 +38,7 @@ public class PromotionAttestationBuilderTests
PromotionId: "prom-1");
var key = new InMemoryKeyProvider("kid-1", Encoding.UTF8.GetBytes("secret"));
var signer = new HmacSigner(key);
var signer = new HmacSigner(key, DefaultCryptoHmac.CreateForTests());
var attestation = await PromotionAttestationBuilder.BuildAsync(
predicate,

View File

@@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Threading.Tasks;
using FluentAssertions;
using StellaOps.Provenance.Attestation;
using StellaOps.Cryptography;
using Xunit;
namespace StellaOps.Provenance.Attestation.Tests;
@@ -28,7 +29,7 @@ public sealed class RotatingSignerTests
var audit = new InMemoryAuditSink();
var rotating = new RotatingKeyProvider(new[] { keyOld, keyNew }, t, audit);
var signer = new HmacSigner(rotating, audit, t);
var signer = new HmacSigner(rotating, DefaultCryptoHmac.CreateForTests(), audit, t);
var req = new SignRequest(
Encoding.UTF8.GetBytes("payload"),

View File

@@ -4,6 +4,7 @@ using System.Threading.Tasks;
using System.Collections.Generic;
using FluentAssertions;
using StellaOps.Provenance.Attestation;
using StellaOps.Cryptography;
using Xunit;
namespace StellaOps.Provenance.Attestation.Tests;
@@ -15,7 +16,7 @@ public class SignerTests
{
var key = new InMemoryKeyProvider("test-key", Encoding.UTF8.GetBytes("secret"));
var audit = new InMemoryAuditSink();
var signer = new HmacSigner(key, audit, TimeProvider.System);
var signer = new HmacSigner(key, DefaultCryptoHmac.CreateForTests(), audit, TimeProvider.System);
var request = new SignRequest(Encoding.UTF8.GetBytes("payload"), "application/json");
@@ -32,7 +33,7 @@ public class SignerTests
{
var key = new InMemoryKeyProvider("test-key", Encoding.UTF8.GetBytes("secret"));
var audit = new InMemoryAuditSink();
var signer = new HmacSigner(key, audit, TimeProvider.System);
var signer = new HmacSigner(key, DefaultCryptoHmac.CreateForTests(), audit, TimeProvider.System);
var request = new SignRequest(
Payload: Encoding.UTF8.GetBytes("payload"),

View File

@@ -13,8 +13,7 @@
<ProjectReference Include="../../StellaOps.Provenance.Attestation/StellaOps.Provenance.Attestation.csproj" />
<ProjectReference Include="../../../../src/__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.0.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
</Project>

View File

@@ -2,6 +2,7 @@ using System.Text;
using FluentAssertions;
using System.Threading.Tasks;
using StellaOps.Provenance.Attestation;
using StellaOps.Cryptography;
using Xunit;
namespace StellaOps.Provenance.Attestation.Tests;
@@ -15,7 +16,7 @@ public class VerificationTests
public async Task Verifier_accepts_valid_signature()
{
var key = new InMemoryKeyProvider("test-key", Encoding.UTF8.GetBytes("secret"));
var signer = new HmacSigner(key);
var signer = new HmacSigner(key, DefaultCryptoHmac.CreateForTests());
var verifier = new HmacVerifier(key);
var request = new SignRequest(Encoding.UTF8.GetBytes(Payload), ContentType);
@@ -30,7 +31,7 @@ public class VerificationTests
public async Task Verifier_rejects_tampered_payload()
{
var key = new InMemoryKeyProvider("test-key", Encoding.UTF8.GetBytes("secret"));
var signer = new HmacSigner(key);
var signer = new HmacSigner(key, DefaultCryptoHmac.CreateForTests());
var verifier = new HmacVerifier(key);
var request = new SignRequest(Encoding.UTF8.GetBytes(Payload), ContentType);

View File

@@ -20,6 +20,4 @@ public sealed class PackRunWorkerOptions
public string ArtifactsPath { get; set; } = Path.Combine(AppContext.BaseDirectory, "artifacts");
public string LogsPath { get; set; } = Path.Combine(AppContext.BaseDirectory, "logs", "runs");
public TaskRunnerStorageOptions Storage { get; set; } = new();
}

View File

@@ -1,31 +0,0 @@
using System.Text.Json.Serialization;
namespace StellaOps.TaskRunner.Core.Configuration;
public static class TaskRunnerStorageModes
{
public const string Filesystem = "filesystem";
public const string Mongo = "mongo";
}
public sealed class TaskRunnerStorageOptions
{
public string Mode { get; set; } = TaskRunnerStorageModes.Filesystem;
public TaskRunnerMongoOptions Mongo { get; set; } = new();
}
public sealed class TaskRunnerMongoOptions
{
public string ConnectionString { get; set; } = "mongodb://127.0.0.1:27017/stellaops-taskrunner";
public string? Database { get; set; }
public string RunsCollection { get; set; } = "pack_runs";
public string LogsCollection { get; set; } = "pack_run_logs";
public string ArtifactsCollection { get; set; } = "pack_artifacts";
public string ApprovalsCollection { get; set; } = "pack_run_approvals";
}

View File

@@ -1,164 +0,0 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver;
using StellaOps.TaskRunner.Core.Configuration;
using StellaOps.TaskRunner.Core.Execution;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class MongoPackRunApprovalStore : IPackRunApprovalStore
{
private readonly IMongoCollection<PackRunApprovalDocument> collection;
public MongoPackRunApprovalStore(IMongoDatabase database, TaskRunnerMongoOptions options)
{
ArgumentNullException.ThrowIfNull(database);
ArgumentNullException.ThrowIfNull(options);
collection = database.GetCollection<PackRunApprovalDocument>(options.ApprovalsCollection);
EnsureIndexes(collection);
}
public async Task SaveAsync(string runId, IReadOnlyList<PackRunApprovalState> approvals, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
ArgumentNullException.ThrowIfNull(approvals);
var filter = Builders<PackRunApprovalDocument>.Filter.Eq(document => document.RunId, runId);
await collection.DeleteManyAsync(filter, cancellationToken).ConfigureAwait(false);
if (approvals.Count == 0)
{
return;
}
var documents = approvals
.Select(approval => PackRunApprovalDocument.FromDomain(runId, approval))
.ToList();
await collection.InsertManyAsync(documents, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<PackRunApprovalState>> GetAsync(string runId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var filter = Builders<PackRunApprovalDocument>.Filter.Eq(document => document.RunId, runId);
var documents = await collection
.Find(filter)
.SortBy(document => document.ApprovalId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return documents
.Select(document => document.ToDomain())
.ToList();
}
public async Task UpdateAsync(string runId, PackRunApprovalState approval, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
ArgumentNullException.ThrowIfNull(approval);
var filter = Builders<PackRunApprovalDocument>.Filter.And(
Builders<PackRunApprovalDocument>.Filter.Eq(document => document.RunId, runId),
Builders<PackRunApprovalDocument>.Filter.Eq(document => document.ApprovalId, approval.ApprovalId));
var existingDocument = await collection
.Find(filter)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
if (existingDocument is null)
{
throw new InvalidOperationException($"Approval '{approval.ApprovalId}' not found for run '{runId}'.");
}
var document = PackRunApprovalDocument.FromDomain(runId, approval, existingDocument.Id);
await collection
.ReplaceOneAsync(filter, document, cancellationToken: cancellationToken)
.ConfigureAwait(false);
}
public static IEnumerable<CreateIndexModel<PackRunApprovalDocument>> GetIndexModels()
{
yield return new CreateIndexModel<PackRunApprovalDocument>(
Builders<PackRunApprovalDocument>.IndexKeys
.Ascending(document => document.RunId)
.Ascending(document => document.ApprovalId),
new CreateIndexOptions { Unique = true, Name = "pack_run_approvals_run_approval" });
yield return new CreateIndexModel<PackRunApprovalDocument>(
Builders<PackRunApprovalDocument>.IndexKeys
.Ascending(document => document.RunId)
.Ascending(document => document.Status),
new CreateIndexOptions { Name = "pack_run_approvals_run_status" });
}
private static void EnsureIndexes(IMongoCollection<PackRunApprovalDocument> target)
=> target.Indexes.CreateMany(GetIndexModels());
public sealed class PackRunApprovalDocument
{
[BsonId]
public ObjectId Id { get; init; }
public string RunId { get; init; } = default!;
public string ApprovalId { get; init; } = default!;
public IReadOnlyList<string> RequiredGrants { get; init; } = Array.Empty<string>();
public IReadOnlyList<string> StepIds { get; init; } = Array.Empty<string>();
public IReadOnlyList<string> Messages { get; init; } = Array.Empty<string>();
public string? ReasonTemplate { get; init; }
public DateTime RequestedAt { get; init; }
public string Status { get; init; } = default!;
public string? ActorId { get; init; }
public DateTime? CompletedAt { get; init; }
public string? Summary { get; init; }
public static PackRunApprovalDocument FromDomain(string runId, PackRunApprovalState approval, ObjectId? id = null)
=> new()
{
Id = id ?? ObjectId.GenerateNewId(),
RunId = runId,
ApprovalId = approval.ApprovalId,
RequiredGrants = approval.RequiredGrants ?? Array.Empty<string>(),
StepIds = approval.StepIds ?? Array.Empty<string>(),
Messages = approval.Messages ?? Array.Empty<string>(),
ReasonTemplate = approval.ReasonTemplate,
RequestedAt = approval.RequestedAt.UtcDateTime,
Status = approval.Status.ToString(),
ActorId = approval.ActorId,
CompletedAt = approval.CompletedAt?.UtcDateTime,
Summary = approval.Summary
};
public PackRunApprovalState ToDomain()
{
var status = Enum.Parse<PackRunApprovalStatus>(Status, ignoreCase: true);
return new PackRunApprovalState(
ApprovalId,
RequiredGrants?.ToList() ?? new List<string>(),
StepIds?.ToList() ?? new List<string>(),
Messages?.ToList() ?? new List<string>(),
ReasonTemplate,
new DateTimeOffset(RequestedAt, TimeSpan.Zero),
status,
ActorId,
CompletedAt is null ? null : new DateTimeOffset(CompletedAt.Value, TimeSpan.Zero),
Summary);
}
}
}

View File

@@ -1,44 +0,0 @@
using MongoDB.Bson;
using MongoDB.Bson.IO;
using MongoDB.Driver;
using StellaOps.TaskRunner.Core.Configuration;
using StellaOps.TaskRunner.Core.Execution;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class MongoPackRunArtifactReader : IPackRunArtifactReader
{
private readonly IMongoCollection<MongoPackRunArtifactUploader.PackRunArtifactDocument> collection;
public MongoPackRunArtifactReader(IMongoDatabase database, TaskRunnerMongoOptions options)
{
ArgumentNullException.ThrowIfNull(database);
ArgumentNullException.ThrowIfNull(options);
collection = database.GetCollection<MongoPackRunArtifactUploader.PackRunArtifactDocument>(options.ArtifactsCollection);
}
public async Task<IReadOnlyList<PackRunArtifactRecord>> ListAsync(string runId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var filter = Builders<MongoPackRunArtifactUploader.PackRunArtifactDocument>.Filter.Eq(doc => doc.RunId, runId);
var documents = await collection
.Find(filter)
.SortBy(doc => doc.Name)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return documents
.Select(doc => new PackRunArtifactRecord(
doc.Name,
doc.Type,
doc.SourcePath,
doc.StoredPath,
doc.Status,
doc.Notes,
new DateTimeOffset(doc.CapturedAt, TimeSpan.Zero),
doc.Expression?.ToJson(new JsonWriterSettings())))
.ToList();
}
}

View File

@@ -1,192 +0,0 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver;
using StellaOps.TaskRunner.Core.Configuration;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class MongoPackRunArtifactUploader : IPackRunArtifactUploader
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private readonly IMongoCollection<PackRunArtifactDocument> collection;
private readonly TimeProvider timeProvider;
private readonly ILogger<MongoPackRunArtifactUploader> logger;
public MongoPackRunArtifactUploader(
IMongoDatabase database,
TaskRunnerMongoOptions options,
TimeProvider? timeProvider,
ILogger<MongoPackRunArtifactUploader> logger)
{
ArgumentNullException.ThrowIfNull(database);
ArgumentNullException.ThrowIfNull(options);
collection = database.GetCollection<PackRunArtifactDocument>(options.ArtifactsCollection);
this.timeProvider = timeProvider ?? TimeProvider.System;
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
EnsureIndexes(collection);
}
public async Task UploadAsync(
PackRunExecutionContext context,
PackRunState state,
IReadOnlyList<TaskPackPlanOutput> outputs,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(state);
ArgumentNullException.ThrowIfNull(outputs);
var filter = Builders<PackRunArtifactDocument>.Filter.Eq(document => document.RunId, context.RunId);
await collection.DeleteManyAsync(filter, cancellationToken).ConfigureAwait(false);
if (outputs.Count == 0)
{
return;
}
var timestamp = timeProvider.GetUtcNow();
var documents = new List<PackRunArtifactDocument>(outputs.Count);
foreach (var output in outputs)
{
cancellationToken.ThrowIfCancellationRequested();
documents.Add(ProcessOutput(context, output, timestamp));
}
await collection.InsertManyAsync(documents, cancellationToken: cancellationToken).ConfigureAwait(false);
}
private PackRunArtifactDocument ProcessOutput(
PackRunExecutionContext context,
TaskPackPlanOutput output,
DateTimeOffset capturedAt)
{
var sourcePath = ResolveString(output.Path);
var expressionNode = ResolveExpression(output.Expression);
string status = "skipped";
string? notes = null;
string? storedPath = null;
if (IsFileOutput(output))
{
if (string.IsNullOrWhiteSpace(sourcePath))
{
status = "unresolved";
notes = "Output path requires runtime value.";
}
else if (!File.Exists(sourcePath))
{
status = "missing";
notes = $"Source file '{sourcePath}' not found.";
logger.LogWarning(
"Pack run {RunId} output {Output} referenced missing file {Path}.",
context.RunId,
output.Name,
sourcePath);
}
else
{
status = "referenced";
storedPath = sourcePath;
}
}
BsonDocument? expressionDocument = null;
if (expressionNode is not null)
{
var json = expressionNode.ToJsonString(SerializerOptions);
expressionDocument = BsonDocument.Parse(json);
status = status is "referenced" ? status : "materialized";
}
return new PackRunArtifactDocument
{
Id = ObjectId.GenerateNewId(),
RunId = context.RunId,
Name = output.Name,
Type = output.Type,
SourcePath = sourcePath,
StoredPath = storedPath,
Status = status,
Notes = notes,
CapturedAt = capturedAt.UtcDateTime,
Expression = expressionDocument
};
}
private static bool IsFileOutput(TaskPackPlanOutput output)
=> string.Equals(output.Type, "file", StringComparison.OrdinalIgnoreCase);
private static string? ResolveString(TaskPackPlanParameterValue? parameter)
{
if (parameter is null || parameter.RequiresRuntimeValue || parameter.Value is null)
{
return null;
}
if (parameter.Value is JsonValue jsonValue && jsonValue.TryGetValue<string>(out var value))
{
return value;
}
return null;
}
private static JsonNode? ResolveExpression(TaskPackPlanParameterValue? parameter)
{
if (parameter is null || parameter.RequiresRuntimeValue)
{
return null;
}
return parameter.Value;
}
public static IEnumerable<CreateIndexModel<PackRunArtifactDocument>> GetIndexModels()
{
yield return new CreateIndexModel<PackRunArtifactDocument>(
Builders<PackRunArtifactDocument>.IndexKeys
.Ascending(document => document.RunId)
.Ascending(document => document.Name),
new CreateIndexOptions { Unique = true, Name = "pack_artifacts_run_name" });
yield return new CreateIndexModel<PackRunArtifactDocument>(
Builders<PackRunArtifactDocument>.IndexKeys
.Ascending(document => document.RunId),
new CreateIndexOptions { Name = "pack_artifacts_run" });
}
private static void EnsureIndexes(IMongoCollection<PackRunArtifactDocument> target)
=> target.Indexes.CreateMany(GetIndexModels());
public sealed class PackRunArtifactDocument
{
[BsonId]
public ObjectId Id { get; init; }
public string RunId { get; init; } = default!;
public string Name { get; init; } = default!;
public string Type { get; init; } = default!;
public string? SourcePath { get; init; }
public string? StoredPath { get; init; }
public string Status { get; init; } = default!;
public string? Notes { get; init; }
public DateTime CapturedAt { get; init; }
public BsonDocument? Expression { get; init; }
}
}

View File

@@ -1,162 +0,0 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver;
using StellaOps.TaskRunner.Core.Configuration;
using StellaOps.TaskRunner.Core.Execution;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class MongoPackRunLogStore : IPackRunLogStore
{
private readonly IMongoCollection<PackRunLogDocument> collection;
public MongoPackRunLogStore(IMongoDatabase database, TaskRunnerMongoOptions options)
{
ArgumentNullException.ThrowIfNull(database);
ArgumentNullException.ThrowIfNull(options);
collection = database.GetCollection<PackRunLogDocument>(options.LogsCollection);
EnsureIndexes(collection);
}
public async Task AppendAsync(string runId, PackRunLogEntry entry, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
ArgumentNullException.ThrowIfNull(entry);
var filter = Builders<PackRunLogDocument>.Filter.Eq(document => document.RunId, runId);
for (var attempt = 0; attempt < 5; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
var last = await collection
.Find(filter)
.SortByDescending(document => document.Sequence)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
var nextSequence = last is null ? 1 : last.Sequence + 1;
var document = PackRunLogDocument.FromDomain(runId, nextSequence, entry);
try
{
await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
return;
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
await Task.Delay(TimeSpan.FromMilliseconds(10), cancellationToken).ConfigureAwait(false);
}
}
throw new InvalidOperationException($"Failed to append log entry for run '{runId}' after multiple attempts.");
}
public async IAsyncEnumerable<PackRunLogEntry> ReadAsync(
string runId,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var filter = Builders<PackRunLogDocument>.Filter.Eq(document => document.RunId, runId);
using var cursor = await collection
.Find(filter)
.SortBy(document => document.Sequence)
.ToCursorAsync(cancellationToken)
.ConfigureAwait(false);
while (await cursor.MoveNextAsync(cancellationToken).ConfigureAwait(false))
{
foreach (var document in cursor.Current)
{
yield return document.ToDomain();
}
}
}
public async Task<bool> ExistsAsync(string runId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var filter = Builders<PackRunLogDocument>.Filter.Eq(document => document.RunId, runId);
return await collection
.Find(filter)
.Limit(1)
.AnyAsync(cancellationToken)
.ConfigureAwait(false);
}
public static IEnumerable<CreateIndexModel<PackRunLogDocument>> GetIndexModels()
{
yield return new CreateIndexModel<PackRunLogDocument>(
Builders<PackRunLogDocument>.IndexKeys
.Ascending(document => document.RunId)
.Ascending(document => document.Sequence),
new CreateIndexOptions { Unique = true, Name = "pack_run_logs_run_sequence" });
yield return new CreateIndexModel<PackRunLogDocument>(
Builders<PackRunLogDocument>.IndexKeys
.Ascending(document => document.RunId)
.Ascending(document => document.Timestamp),
new CreateIndexOptions { Name = "pack_run_logs_run_timestamp" });
}
private static void EnsureIndexes(IMongoCollection<PackRunLogDocument> target)
=> target.Indexes.CreateMany(GetIndexModels());
public sealed class PackRunLogDocument
{
[BsonId]
public ObjectId Id { get; init; }
public string RunId { get; init; } = default!;
public long Sequence { get; init; }
public DateTime Timestamp { get; init; }
public string Level { get; init; } = default!;
public string EventType { get; init; } = default!;
public string Message { get; init; } = default!;
public string? StepId { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
public static PackRunLogDocument FromDomain(string runId, long sequence, PackRunLogEntry entry)
=> new()
{
Id = ObjectId.GenerateNewId(),
RunId = runId,
Sequence = sequence,
Timestamp = entry.Timestamp.UtcDateTime,
Level = entry.Level,
EventType = entry.EventType,
Message = entry.Message,
StepId = entry.StepId,
Metadata = entry.Metadata is null
? null
: new Dictionary<string, string>(entry.Metadata, StringComparer.Ordinal)
};
public PackRunLogEntry ToDomain()
{
IReadOnlyDictionary<string, string>? metadata = Metadata is null
? null
: new Dictionary<string, string>(Metadata, StringComparer.Ordinal);
return new PackRunLogEntry(
new DateTimeOffset(Timestamp, TimeSpan.Zero),
Level,
EventType,
Message,
StepId,
metadata);
}
}
}

View File

@@ -1,67 +0,0 @@
using System.Text.Json;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.TaskRunner.Core.Configuration;
using StellaOps.TaskRunner.Core.Execution;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class MongoPackRunProvenanceWriter : IPackRunProvenanceWriter
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private readonly IMongoCollection<ProvenanceDocument> collection;
private readonly TimeProvider timeProvider;
public MongoPackRunProvenanceWriter(IMongoDatabase database, TaskRunnerMongoOptions options, TimeProvider? timeProvider = null)
{
ArgumentNullException.ThrowIfNull(database);
ArgumentNullException.ThrowIfNull(options);
collection = database.GetCollection<ProvenanceDocument>(options.ArtifactsCollection);
this.timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task WriteAsync(PackRunExecutionContext context, PackRunState state, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(state);
var completedAt = timeProvider.GetUtcNow();
var manifest = ProvenanceManifestFactory.Create(context, state, completedAt);
var manifestJson = JsonSerializer.Serialize(manifest, SerializerOptions);
var manifestDocument = BsonDocument.Parse(manifestJson);
var document = new ProvenanceDocument
{
RunId = context.RunId,
Name = "provenance-manifest",
Type = "object",
Status = "materialized",
CapturedAt = completedAt.UtcDateTime,
Expression = manifestDocument
};
var filter = Builders<ProvenanceDocument>.Filter.And(
Builders<ProvenanceDocument>.Filter.Eq(doc => doc.RunId, context.RunId),
Builders<ProvenanceDocument>.Filter.Eq(doc => doc.Name, document.Name));
var options = new ReplaceOptions { IsUpsert = true };
await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
}
private sealed class ProvenanceDocument
{
public string RunId { get; init; } = default!;
public string Name { get; init; } = default!;
public string Type { get; init; } = default!;
public string Status { get; init; } = default!;
public DateTime CapturedAt { get; init; }
public BsonDocument Expression { get; init; } = default!;
}
}

View File

@@ -1,216 +0,0 @@
using System.Collections.ObjectModel;
using System.Text.Json;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver;
using StellaOps.TaskRunner.Core.Configuration;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class MongoPackRunStateStore : IPackRunStateStore
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private readonly IMongoCollection<PackRunStateDocument> collection;
public MongoPackRunStateStore(IMongoDatabase database, TaskRunnerMongoOptions options)
{
ArgumentNullException.ThrowIfNull(database);
ArgumentNullException.ThrowIfNull(options);
collection = database.GetCollection<PackRunStateDocument>(options.RunsCollection);
EnsureIndexes(collection);
}
public async Task<PackRunState?> GetAsync(string runId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var filter = Builders<PackRunStateDocument>.Filter.Eq(document => document.RunId, runId);
var document = await collection
.Find(filter)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return document?.ToDomain();
}
public async Task SaveAsync(PackRunState state, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(state);
var document = PackRunStateDocument.FromDomain(state);
var filter = Builders<PackRunStateDocument>.Filter.Eq(existing => existing.RunId, state.RunId);
await collection
.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken)
.ConfigureAwait(false);
}
public async Task<IReadOnlyList<PackRunState>> ListAsync(CancellationToken cancellationToken)
{
var documents = await collection
.Find(FilterDefinition<PackRunStateDocument>.Empty)
.SortByDescending(document => document.UpdatedAt)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return documents
.Select(document => document.ToDomain())
.ToList();
}
public static IEnumerable<CreateIndexModel<PackRunStateDocument>> GetIndexModels()
{
yield return new CreateIndexModel<PackRunStateDocument>(
Builders<PackRunStateDocument>.IndexKeys.Descending(document => document.UpdatedAt),
new CreateIndexOptions { Name = "pack_runs_updatedAt_desc" });
yield return new CreateIndexModel<PackRunStateDocument>(
Builders<PackRunStateDocument>.IndexKeys
.Ascending(document => document.TenantId)
.Descending(document => document.UpdatedAt),
new CreateIndexOptions { Name = "pack_runs_tenant_updatedAt_desc", Sparse = true });
}
private static void EnsureIndexes(IMongoCollection<PackRunStateDocument> target)
=> target.Indexes.CreateMany(GetIndexModels());
public sealed class PackRunStateDocument
{
[BsonId]
public string RunId { get; init; } = default!;
public string PlanHash { get; init; } = default!;
public BsonDocument Plan { get; init; } = default!;
public BsonDocument FailurePolicy { get; init; } = default!;
public DateTime RequestedAt { get; init; }
public DateTime CreatedAt { get; init; }
public DateTime UpdatedAt { get; init; }
public List<PackRunStepDocument> Steps { get; init; } = new();
public string? TenantId { get; init; }
public static PackRunStateDocument FromDomain(PackRunState state)
{
var planDocument = BsonDocument.Parse(JsonSerializer.Serialize(state.Plan, SerializerOptions));
var failurePolicyDocument = BsonDocument.Parse(JsonSerializer.Serialize(state.FailurePolicy, SerializerOptions));
var steps = state.Steps.Values
.OrderBy(step => step.StepId, StringComparer.Ordinal)
.Select(PackRunStepDocument.FromDomain)
.ToList();
return new PackRunStateDocument
{
RunId = state.RunId,
PlanHash = state.PlanHash,
Plan = planDocument,
FailurePolicy = failurePolicyDocument,
RequestedAt = state.RequestedAt.UtcDateTime,
CreatedAt = state.CreatedAt.UtcDateTime,
UpdatedAt = state.UpdatedAt.UtcDateTime,
Steps = steps,
TenantId = state.TenantId
};
}
public PackRunState ToDomain()
{
var planJson = Plan.ToJson();
var plan = JsonSerializer.Deserialize<TaskPackPlan>(planJson, SerializerOptions)
?? throw new InvalidOperationException("Failed to deserialize stored TaskPackPlan.");
var failurePolicyJson = FailurePolicy.ToJson();
var failurePolicy = JsonSerializer.Deserialize<TaskPackPlanFailurePolicy>(failurePolicyJson, SerializerOptions)
?? throw new InvalidOperationException("Failed to deserialize stored TaskPackPlanFailurePolicy.");
var stepRecords = Steps
.Select(step => step.ToDomain())
.ToDictionary(record => record.StepId, record => record, StringComparer.Ordinal);
return new PackRunState(
RunId,
PlanHash,
plan,
failurePolicy,
new DateTimeOffset(RequestedAt, TimeSpan.Zero),
new DateTimeOffset(CreatedAt, TimeSpan.Zero),
new DateTimeOffset(UpdatedAt, TimeSpan.Zero),
new ReadOnlyDictionary<string, PackRunStepStateRecord>(stepRecords),
TenantId);
}
}
public sealed class PackRunStepDocument
{
public string StepId { get; init; } = default!;
public string Kind { get; init; } = default!;
public bool Enabled { get; init; }
public bool ContinueOnError { get; init; }
public int? MaxParallel { get; init; }
public string? ApprovalId { get; init; }
public string? GateMessage { get; init; }
public string Status { get; init; } = default!;
public int Attempts { get; init; }
public DateTime? LastTransitionAt { get; init; }
public DateTime? NextAttemptAt { get; init; }
public string? StatusReason { get; init; }
public static PackRunStepDocument FromDomain(PackRunStepStateRecord record)
=> new()
{
StepId = record.StepId,
Kind = record.Kind.ToString(),
Enabled = record.Enabled,
ContinueOnError = record.ContinueOnError,
MaxParallel = record.MaxParallel,
ApprovalId = record.ApprovalId,
GateMessage = record.GateMessage,
Status = record.Status.ToString(),
Attempts = record.Attempts,
LastTransitionAt = record.LastTransitionAt?.UtcDateTime,
NextAttemptAt = record.NextAttemptAt?.UtcDateTime,
StatusReason = record.StatusReason
};
public PackRunStepStateRecord ToDomain()
{
var kind = Enum.Parse<PackRunStepKind>(Kind, ignoreCase: true);
var status = Enum.Parse<PackRunStepExecutionStatus>(Status, ignoreCase: true);
return new PackRunStepStateRecord(
StepId,
kind,
Enabled,
ContinueOnError,
MaxParallel,
ApprovalId,
GateMessage,
status,
Attempts,
LastTransitionAt is null ? null : new DateTimeOffset(LastTransitionAt.Value, TimeSpan.Zero),
NextAttemptAt is null ? null : new DateTimeOffset(NextAttemptAt.Value, TimeSpan.Zero),
StatusReason);
}
}
}

View File

@@ -3,7 +3,6 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
<ProjectReference Include="..\StellaOps.TaskRunner.Core\StellaOps.TaskRunner.Core.csproj" />
</ItemGroup>

View File

@@ -1,62 +0,0 @@
using MongoDB.Driver;
using StellaOps.TaskRunner.Infrastructure.Execution;
using Xunit;
namespace StellaOps.TaskRunner.Tests;
public sealed class MongoIndexModelTests
{
[Fact]
public void StateStore_indexes_match_contract()
{
var models = MongoPackRunStateStore.GetIndexModels().ToArray();
Assert.Collection(models,
model => Assert.Equal("pack_runs_updatedAt_desc", model.Options.Name),
model => Assert.Equal("pack_runs_tenant_updatedAt_desc", model.Options.Name));
Assert.True(models[1].Options.Sparse ?? false);
}
[Fact]
public void LogStore_indexes_match_contract()
{
var models = MongoPackRunLogStore.GetIndexModels().ToArray();
Assert.Collection(models,
model =>
{
Assert.Equal("pack_run_logs_run_sequence", model.Options.Name);
Assert.True(model.Options.Unique ?? false);
},
model => Assert.Equal("pack_run_logs_run_timestamp", model.Options.Name));
}
[Fact]
public void ArtifactStore_indexes_match_contract()
{
var models = MongoPackRunArtifactUploader.GetIndexModels().ToArray();
Assert.Collection(models,
model =>
{
Assert.Equal("pack_artifacts_run_name", model.Options.Name);
Assert.True(model.Options.Unique ?? false);
},
model => Assert.Equal("pack_artifacts_run", model.Options.Name));
}
[Fact]
public void ApprovalStore_indexes_match_contract()
{
var models = MongoPackRunApprovalStore.GetIndexModels().ToArray();
Assert.Collection(models,
model =>
{
Assert.Equal("pack_run_approvals_run_approval", model.Options.Name);
Assert.True(model.Options.Unique ?? false);
},
model => Assert.Equal("pack_run_approvals_run_status", model.Options.Name));
}
}

View File

@@ -1,196 +0,0 @@
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Driver;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Execution.Simulation;
using StellaOps.TaskRunner.Core.Planning;
using StellaOps.TaskRunner.Core.TaskPacks;
using StellaOps.TaskRunner.Infrastructure.Execution;
using Xunit;
using Xunit.Sdk;
namespace StellaOps.TaskRunner.Tests;
public sealed class MongoPackRunStoresTests
{
[Fact]
public async Task StateStore_RoundTrips_State()
{
using var context = MongoTaskRunnerTestContext.Create();
var mongoOptions = context.CreateMongoOptions();
var stateStore = new MongoPackRunStateStore(context.Database, mongoOptions);
var plan = CreatePlan();
var executionContext = new PackRunExecutionContext("mongo-run-state", plan, DateTimeOffset.UtcNow);
var graph = new PackRunExecutionGraphBuilder().Build(plan);
var simulationEngine = new PackRunSimulationEngine();
var state = PackRunStateFactory.CreateInitialState(executionContext, graph, simulationEngine, DateTimeOffset.UtcNow);
await stateStore.SaveAsync(state, CancellationToken.None);
var reloaded = await stateStore.GetAsync(state.RunId, CancellationToken.None);
Assert.NotNull(reloaded);
Assert.Equal(state.RunId, reloaded!.RunId);
Assert.Equal(state.PlanHash, reloaded.PlanHash);
Assert.Equal(state.Steps.Count, reloaded.Steps.Count);
}
[Fact]
public async Task LogStore_Appends_And_Reads_In_Order()
{
using var context = MongoTaskRunnerTestContext.Create();
var mongoOptions = context.CreateMongoOptions();
var logStore = new MongoPackRunLogStore(context.Database, mongoOptions);
var runId = "mongo-log";
await logStore.AppendAsync(runId, new PackRunLogEntry(DateTimeOffset.UtcNow, "info", "run.created", "created", null, null), CancellationToken.None);
await logStore.AppendAsync(runId, new PackRunLogEntry(DateTimeOffset.UtcNow.AddSeconds(1), "warn", "step.retry", "retry", "step-a", new Dictionary<string, string> { ["attempt"] = "2" }), CancellationToken.None);
var entries = new List<PackRunLogEntry>();
await foreach (var entry in logStore.ReadAsync(runId, CancellationToken.None))
{
entries.Add(entry);
}
Assert.Equal(2, entries.Count);
Assert.Equal("run.created", entries[0].EventType);
Assert.Equal("step.retry", entries[1].EventType);
Assert.Equal("step-a", entries[1].StepId);
Assert.True(await logStore.ExistsAsync(runId, CancellationToken.None));
}
[Fact]
public async Task ApprovalStore_RoundTrips_And_Updates()
{
using var context = MongoTaskRunnerTestContext.Create();
var mongoOptions = context.CreateMongoOptions();
var approvalStore = new MongoPackRunApprovalStore(context.Database, mongoOptions);
var runId = "mongo-approvals";
var approval = new PackRunApprovalState(
"security-review",
new[] { "packs.approve" },
new[] { "step-plan" },
Array.Empty<string>(),
reasonTemplate: "Security approval required.",
DateTimeOffset.UtcNow,
PackRunApprovalStatus.Pending);
await approvalStore.SaveAsync(runId, new[] { approval }, CancellationToken.None);
var approvals = await approvalStore.GetAsync(runId, CancellationToken.None);
Assert.Single(approvals);
var updated = approval.Approve("approver", DateTimeOffset.UtcNow, "Approved");
await approvalStore.UpdateAsync(runId, updated, CancellationToken.None);
approvals = await approvalStore.GetAsync(runId, CancellationToken.None);
Assert.Single(approvals);
Assert.Equal(PackRunApprovalStatus.Approved, approvals[0].Status);
Assert.Equal("approver", approvals[0].ActorId);
}
[Fact]
public async Task ArtifactUploader_Persists_Metadata()
{
using var context = MongoTaskRunnerTestContext.Create();
var mongoOptions = context.CreateMongoOptions();
var database = context.Database;
var artifactUploader = new MongoPackRunArtifactUploader(
database,
mongoOptions,
TimeProvider.System,
NullLogger<MongoPackRunArtifactUploader>.Instance);
var plan = CreatePlanWithOutputs(out var outputFile);
try
{
var executionContext = new PackRunExecutionContext("mongo-artifacts", plan, DateTimeOffset.UtcNow);
var graph = new PackRunExecutionGraphBuilder().Build(plan);
var simulationEngine = new PackRunSimulationEngine();
var state = PackRunStateFactory.CreateInitialState(executionContext, graph, simulationEngine, DateTimeOffset.UtcNow);
await artifactUploader.UploadAsync(executionContext, state, plan.Outputs, CancellationToken.None);
var documents = await database
.GetCollection<MongoPackRunArtifactUploader.PackRunArtifactDocument>(mongoOptions.ArtifactsCollection)
.Find(Builders<MongoPackRunArtifactUploader.PackRunArtifactDocument>.Filter.Empty)
.ToListAsync(TestContext.Current.CancellationToken);
var bundleDocument = Assert.Single(documents, d => string.Equals(d.Name, "bundlePath", StringComparison.Ordinal));
Assert.Equal("file", bundleDocument.Type);
Assert.Equal(outputFile, bundleDocument.SourcePath);
Assert.Equal("referenced", bundleDocument.Status);
}
finally
{
if (File.Exists(outputFile))
{
File.Delete(outputFile);
}
}
}
private static TaskPackPlan CreatePlan()
{
var manifest = TestManifests.Load(TestManifests.Sample);
var planner = new TaskPackPlanner();
var result = planner.Plan(manifest);
if (!result.Success || result.Plan is null)
{
Assert.Skip("Failed to build task pack plan for Mongo tests.");
throw new InvalidOperationException();
}
return result.Plan;
}
private static TaskPackPlan CreatePlanWithOutputs(out string outputFile)
{
var manifest = TestManifests.Load(TestManifests.Output);
var planner = new TaskPackPlanner();
var result = planner.Plan(manifest);
if (!result.Success || result.Plan is null)
{
Assert.Skip("Failed to build output plan for Mongo tests.");
throw new InvalidOperationException();
}
// Materialize a fake output file referenced by the plan.
outputFile = Path.Combine(Path.GetTempPath(), $"taskrunner-output-{Guid.NewGuid():N}.txt");
File.WriteAllText(outputFile, "fixture");
// Update the plan output path parameter to point at the file we just created.
var originalPlan = result.Plan;
var resolvedFile = outputFile;
var outputs = originalPlan.Outputs
.Select(output =>
{
if (!string.Equals(output.Name, "bundlePath", StringComparison.Ordinal))
{
return output;
}
var node = JsonNode.Parse($"\"{resolvedFile.Replace("\\", "\\\\")}\"");
var parameter = new TaskPackPlanParameterValue(node, null, null, false);
return output with { Path = parameter };
})
.ToArray();
return new TaskPackPlan(
originalPlan.Metadata,
originalPlan.Inputs,
originalPlan.Steps,
originalPlan.Hash,
originalPlan.Approvals,
originalPlan.Secrets,
outputs,
originalPlan.FailurePolicy);
}
}

View File

@@ -1,89 +0,0 @@
using Mongo2Go;
using MongoDB.Driver;
using StellaOps.TaskRunner.Core.Configuration;
using StellaOps.Testing;
using Xunit;
namespace StellaOps.TaskRunner.Tests;
internal sealed class MongoTaskRunnerTestContext : IAsyncDisposable, IDisposable
{
private readonly MongoDbRunner? runner;
private readonly string databaseName;
private readonly IMongoClient client;
private readonly string connectionString;
private MongoTaskRunnerTestContext(
IMongoClient client,
IMongoDatabase database,
MongoDbRunner? runner,
string databaseName,
string connectionString)
{
this.client = client;
Database = database;
this.runner = runner;
this.databaseName = databaseName;
this.connectionString = connectionString;
}
public IMongoDatabase Database { get; }
public static MongoTaskRunnerTestContext Create()
{
OpenSslLegacyShim.EnsureOpenSsl11();
var uri = Environment.GetEnvironmentVariable("STELLAOPS_TEST_MONGO_URI");
if (!string.IsNullOrWhiteSpace(uri))
{
try
{
var url = MongoUrl.Create(uri);
var client = new MongoClient(url);
var databaseName = string.IsNullOrWhiteSpace(url.DatabaseName)
? $"taskrunner-tests-{Guid.NewGuid():N}"
: url.DatabaseName;
var database = client.GetDatabase(databaseName);
return new MongoTaskRunnerTestContext(client, database, runner: null, databaseName, uri);
}
catch (Exception ex)
{
Assert.Skip($"Failed to connect to MongoDB using STELLAOPS_TEST_MONGO_URI: {ex.Message}");
throw new InvalidOperationException(); // Unreachable
}
}
try
{
var runner = MongoDbRunner.Start(singleNodeReplSet: false);
var client = new MongoClient(runner.ConnectionString);
var databaseName = $"taskrunner-tests-{Guid.NewGuid():N}";
var database = client.GetDatabase(databaseName);
return new MongoTaskRunnerTestContext(client, database, runner, databaseName, runner.ConnectionString);
}
catch (Exception ex)
{
Assert.Skip($"Unable to start embedded MongoDB (Mongo2Go): {ex.Message}");
throw new InvalidOperationException(); // Unreachable
}
}
public async ValueTask DisposeAsync()
{
await client.DropDatabaseAsync(databaseName);
runner?.Dispose();
}
public void Dispose()
{
client.DropDatabase(databaseName);
runner?.Dispose();
}
public TaskRunnerMongoOptions CreateMongoOptions()
=> new()
{
ConnectionString = connectionString,
Database = databaseName
};
}

View File

@@ -1,6 +1,5 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using MongoDB.Driver;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Execution.Simulation;
using StellaOps.TaskRunner.Core.Planning;
@@ -40,30 +39,6 @@ public sealed class PackRunProvenanceWriterTests
}
}
[Fact]
public async Task Mongo_writer_upserts_manifest()
{
await using var mongo = MongoTaskRunnerTestContext.Create();
var (context, state) = CreateRunState();
var completedAt = new DateTimeOffset(2025, 11, 30, 12, 0, 0, TimeSpan.Zero);
var ct = TestContext.Current.CancellationToken;
var options = mongo.CreateMongoOptions();
var writer = new MongoPackRunProvenanceWriter(mongo.Database, options, new FixedTimeProvider(completedAt));
await writer.WriteAsync(context, state, ct);
var collection = mongo.Database.GetCollection<MongoDB.Bson.BsonDocument>(options.ArtifactsCollection);
var saved = await collection
.Find(Builders<MongoDB.Bson.BsonDocument>.Filter.Eq("RunId", context.RunId))
.FirstOrDefaultAsync(ct);
Assert.NotNull(saved);
var manifest = saved!["Expression"].AsBsonDocument;
Assert.Equal("run-test", manifest["runId"].AsString);
Assert.Equal("tenant-alpha", manifest["tenantId"].AsString);
Assert.Equal(context.Plan.Hash, manifest["planHash"].AsString);
}
private static (PackRunExecutionContext Context, PackRunState State) CreateRunState()
{
var loader = new TaskPackManifestLoader();

View File

@@ -14,7 +14,6 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Mongo2Go" Version="4.1.0" />
<PackageReference Include="xunit.v3" Version="3.0.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3" />
</ItemGroup>
@@ -36,12 +35,6 @@
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<None Include="..\..\..\..\tests\native/openssl-1.1/linux-x64/*"
Link="native/linux-x64/%(Filename)%(Extension)"
CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

View File

@@ -6,7 +6,6 @@ using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using MongoDB.Driver;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
using Microsoft.AspNetCore.Http;
@@ -50,52 +49,26 @@ builder.Services.AddStellaOpsTelemetry(
.AddRuntimeInstrumentation()
.AddMeter(TaskRunnerTelemetry.MeterName));
var storageOptions = builder.Configuration.GetSection("TaskRunner:Storage").Get<TaskRunnerStorageOptions>() ?? new TaskRunnerStorageOptions();
builder.Services.AddSingleton(storageOptions);
if (string.Equals(storageOptions.Mode, TaskRunnerStorageModes.Mongo, StringComparison.OrdinalIgnoreCase))
builder.Services.AddSingleton<IPackRunApprovalStore>(sp =>
{
builder.Services.AddSingleton(storageOptions.Mongo);
builder.Services.AddSingleton<IMongoClient>(_ => new MongoClient(storageOptions.Mongo.ConnectionString));
builder.Services.AddSingleton<IMongoDatabase>(sp =>
{
var mongoOptions = storageOptions.Mongo;
var client = sp.GetRequiredService<IMongoClient>();
var mongoUrl = MongoUrl.Create(mongoOptions.ConnectionString);
var databaseName = !string.IsNullOrWhiteSpace(mongoOptions.Database)
? mongoOptions.Database
: mongoUrl.DatabaseName ?? "stellaops-taskrunner";
return client.GetDatabase(databaseName);
});
builder.Services.AddSingleton<IPackRunStateStore, MongoPackRunStateStore>();
builder.Services.AddSingleton<IPackRunLogStore, MongoPackRunLogStore>();
builder.Services.AddSingleton<IPackRunApprovalStore, MongoPackRunApprovalStore>();
builder.Services.AddSingleton<IPackRunArtifactReader, MongoPackRunArtifactReader>();
}
else
var options = sp.GetRequiredService<IOptions<TaskRunnerServiceOptions>>().Value;
return new FilePackRunApprovalStore(options.ApprovalStorePath);
});
builder.Services.AddSingleton<IPackRunStateStore>(sp =>
{
builder.Services.AddSingleton<IPackRunApprovalStore>(sp =>
{
var options = sp.GetRequiredService<IOptions<TaskRunnerServiceOptions>>().Value;
return new FilePackRunApprovalStore(options.ApprovalStorePath);
});
builder.Services.AddSingleton<IPackRunStateStore>(sp =>
{
var options = sp.GetRequiredService<IOptions<TaskRunnerServiceOptions>>().Value;
return new FilePackRunStateStore(options.RunStatePath);
});
builder.Services.AddSingleton<IPackRunLogStore>(sp =>
{
var options = sp.GetRequiredService<IOptions<TaskRunnerServiceOptions>>().Value;
return new FilePackRunLogStore(options.LogsPath);
});
builder.Services.AddSingleton<IPackRunArtifactReader>(sp =>
{
var options = sp.GetRequiredService<IOptions<TaskRunnerServiceOptions>>().Value;
return new FilesystemPackRunArtifactReader(options.ArtifactsPath);
});
}
var options = sp.GetRequiredService<IOptions<TaskRunnerServiceOptions>>().Value;
return new FilePackRunStateStore(options.RunStatePath);
});
builder.Services.AddSingleton<IPackRunLogStore>(sp =>
{
var options = sp.GetRequiredService<IOptions<TaskRunnerServiceOptions>>().Value;
return new FilePackRunLogStore(options.LogsPath);
});
builder.Services.AddSingleton<IPackRunArtifactReader>(sp =>
{
var options = sp.GetRequiredService<IOptions<TaskRunnerServiceOptions>>().Value;
return new FilesystemPackRunArtifactReader(options.ArtifactsPath);
});
builder.Services.AddSingleton(sp =>
{

View File

@@ -1,5 +1,3 @@
using StellaOps.TaskRunner.Core.Configuration;
namespace StellaOps.TaskRunner.WebService;
public sealed class TaskRunnerServiceOptions
@@ -10,6 +8,4 @@ public sealed class TaskRunnerServiceOptions
public string ArchivePath { get; set; } = Path.Combine(AppContext.BaseDirectory, "queue", "archive");
public string LogsPath { get; set; } = Path.Combine(AppContext.BaseDirectory, "logs", "runs");
public string ArtifactsPath { get; set; } = Path.Combine(AppContext.BaseDirectory, "artifacts");
public TaskRunnerStorageOptions Storage { get; set; } = new();
}

View File

@@ -1,5 +1,4 @@
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StellaOps.AirGap.Policy;
using StellaOps.TaskRunner.Core.Configuration;
using StellaOps.TaskRunner.Core.Execution;
@@ -7,7 +6,7 @@ using StellaOps.TaskRunner.Core.Execution.Simulation;
using StellaOps.TaskRunner.Infrastructure.Execution;
using StellaOps.TaskRunner.Worker.Services;
using StellaOps.Telemetry.Core;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddAirGapEgressPolicy(builder.Configuration, sectionName: "AirGap");
@@ -51,67 +50,34 @@ builder.Services.AddStellaOpsTelemetry(
.AddRuntimeInstrumentation()
.AddMeter(TaskRunnerTelemetry.MeterName));
var workerStorageOptions = builder.Configuration.GetSection("Worker:Storage").Get<TaskRunnerStorageOptions>() ?? new TaskRunnerStorageOptions();
builder.Services.AddSingleton(workerStorageOptions);
if (string.Equals(workerStorageOptions.Mode, TaskRunnerStorageModes.Mongo, StringComparison.OrdinalIgnoreCase))
builder.Services.AddSingleton<IPackRunApprovalStore>(sp =>
{
builder.Services.AddSingleton(workerStorageOptions.Mongo);
builder.Services.AddSingleton<IMongoClient>(_ => new MongoClient(workerStorageOptions.Mongo.ConnectionString));
builder.Services.AddSingleton<IMongoDatabase>(sp =>
{
var mongoOptions = workerStorageOptions.Mongo;
var client = sp.GetRequiredService<IMongoClient>();
var mongoUrl = MongoUrl.Create(mongoOptions.ConnectionString);
var databaseName = !string.IsNullOrWhiteSpace(mongoOptions.Database)
? mongoOptions.Database
: mongoUrl.DatabaseName ?? "stellaops-taskrunner";
return client.GetDatabase(databaseName);
});
builder.Services.AddSingleton<IPackRunStateStore, MongoPackRunStateStore>();
builder.Services.AddSingleton<IPackRunLogStore, MongoPackRunLogStore>();
builder.Services.AddSingleton<IPackRunApprovalStore, MongoPackRunApprovalStore>();
builder.Services.AddSingleton<IPackRunArtifactUploader, MongoPackRunArtifactUploader>();
builder.Services.AddSingleton<IPackRunProvenanceWriter>(sp =>
{
var db = sp.GetRequiredService<IMongoDatabase>();
var options = sp.GetRequiredService<TaskRunnerMongoOptions>();
var timeProvider = sp.GetRequiredService<TimeProvider>();
return new MongoPackRunProvenanceWriter(db, options, timeProvider);
});
}
else
var options = sp.GetRequiredService<IOptions<PackRunWorkerOptions>>();
return new FilePackRunApprovalStore(options.Value.ApprovalStorePath);
});
builder.Services.AddSingleton<IPackRunStateStore>(sp =>
{
builder.Services.AddSingleton<IPackRunApprovalStore>(sp =>
{
var options = sp.GetRequiredService<IOptions<PackRunWorkerOptions>>();
return new FilePackRunApprovalStore(options.Value.ApprovalStorePath);
});
builder.Services.AddSingleton<IPackRunStateStore>(sp =>
{
var options = sp.GetRequiredService<IOptions<PackRunWorkerOptions>>();
return new FilePackRunStateStore(options.Value.RunStatePath);
});
builder.Services.AddSingleton<IPackRunLogStore>(sp =>
{
var options = sp.GetRequiredService<IOptions<PackRunWorkerOptions>>();
return new FilePackRunLogStore(options.Value.LogsPath);
});
builder.Services.AddSingleton<IPackRunArtifactUploader>(sp =>
{
var options = sp.GetRequiredService<IOptions<PackRunWorkerOptions>>().Value;
var timeProvider = sp.GetRequiredService<TimeProvider>();
var logger = sp.GetRequiredService<ILogger<FilesystemPackRunArtifactUploader>>();
return new FilesystemPackRunArtifactUploader(options.ArtifactsPath, timeProvider, logger);
});
builder.Services.AddSingleton<IPackRunProvenanceWriter>(sp =>
{
var options = sp.GetRequiredService<IOptions<PackRunWorkerOptions>>().Value;
var timeProvider = sp.GetRequiredService<TimeProvider>();
return new FilesystemPackRunProvenanceWriter(options.ArtifactsPath, timeProvider);
});
}
var options = sp.GetRequiredService<IOptions<PackRunWorkerOptions>>();
return new FilePackRunStateStore(options.Value.RunStatePath);
});
builder.Services.AddSingleton<IPackRunLogStore>(sp =>
{
var options = sp.GetRequiredService<IOptions<PackRunWorkerOptions>>();
return new FilePackRunLogStore(options.Value.LogsPath);
});
builder.Services.AddSingleton<IPackRunArtifactUploader>(sp =>
{
var options = sp.GetRequiredService<IOptions<PackRunWorkerOptions>>().Value;
var timeProvider = sp.GetRequiredService<TimeProvider>();
var logger = sp.GetRequiredService<ILogger<FilesystemPackRunArtifactUploader>>();
return new FilesystemPackRunArtifactUploader(options.ArtifactsPath, timeProvider, logger);
});
builder.Services.AddSingleton<IPackRunProvenanceWriter>(sp =>
{
var options = sp.GetRequiredService<IOptions<PackRunWorkerOptions>>().Value;
var timeProvider = sp.GetRequiredService<TimeProvider>();
return new FilesystemPackRunProvenanceWriter(options.ArtifactsPath, timeProvider);
});
builder.Services.AddHostedService<PackRunWorkerService>();

View File

@@ -18,4 +18,8 @@
| TASKRUN-OBS-53-001 | BLOCKED (2025-11-25) | SPRINT_0157_0001_0001_taskrunner_i | TASKRUN-OBS-52-001 | Evidence locker snapshots; blocked: waiting on timeline schema/pointer contract. |
| TASKRUN-GAPS-157-014 | DONE (2025-12-05) | SPRINT_0157_0001_0001_taskrunner_i | — | TP1TP10 remediation: canonical plan-hash recipe, inputs.lock evidence, approval DSSE ledger, redaction, deterministic RNG/time, sandbox/egress quotas, registry signing + SBOM + revocation, offline bundle schema + verifier script, SLO/alerting, fail-closed gates. |
| MR-T10.7.1 | DONE (2025-12-11) | SPRINT_3410_0001_0001_mongodb_final_removal | ƒ?" | TaskRunner WebService now filesystem-only; removed Mongo wiring and dependencies. |
| MR-T10.7.2 | DONE (2025-12-11) | SPRINT_3410_0001_0001_mongodb_final_removal | MR-T10.7.1 | TaskRunner Worker uses filesystem storage only; removed Mongo wiring and options. |
| MR-T10.7.3 | DONE (2025-12-11) | SPRINT_3410_0001_0001_mongodb_final_removal | MR-T10.7.2 | Removed Mongo storage implementations/tests; dropped Mongo2Go dependency. |
Status source of truth: `docs/implplan/SPRINT_0157_0001_0001_taskrunner_i.md`. Update both files together. Keep UTC dates when advancing status.

View File

@@ -0,0 +1,485 @@
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { map, catchError, delay } from 'rxjs/operators';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import { CONSOLE_API_BASE_URL } from './console-status.client';
import {
ConsoleSearchResponse,
ConsoleSearchQueryOptions,
ConsoleDownloadResponse,
ConsoleDownloadQueryOptions,
SearchResultItem,
SearchSeverity,
SearchPolicyBadge,
SearchReachability,
SearchVexState,
DownloadManifest,
DownloadManifestItem,
} from './console-search.models';
import { generateTraceId } from './trace.util';
/**
* Console Search & Downloads API interface.
* Implements WEB-CONSOLE-23-004 and WEB-CONSOLE-23-005.
*/
export interface ConsoleSearchApi {
/** Search with deterministic ranking and caching. */
search(options?: ConsoleSearchQueryOptions): Observable<ConsoleSearchResponse>;
/** Get download manifest. */
getDownloads(options?: ConsoleDownloadQueryOptions): Observable<ConsoleDownloadResponse>;
/** Get download manifest for specific export. */
getDownload(exportId: string, options?: ConsoleDownloadQueryOptions): Observable<ConsoleDownloadResponse>;
}
export const CONSOLE_SEARCH_API = new InjectionToken<ConsoleSearchApi>('CONSOLE_SEARCH_API');
/**
* Deterministic ranking comparator.
* Order: severity (desc) → exploitScore (desc) → reachability (reachable > unknown > unreachable)
* → policyBadge (fail > warn > pass > waived) → vexState (under_investigation > fixed > not_affected > unknown)
* → findingId (asc)
*/
function compareSearchResults(a: SearchResultItem, b: SearchResultItem): number {
// Severity order (higher = more severe)
const severityOrder: Record<SearchSeverity, number> = {
critical: 5, high: 4, medium: 3, low: 2, info: 1, unknown: 0,
};
const sevDiff = severityOrder[b.severity] - severityOrder[a.severity];
if (sevDiff !== 0) return sevDiff;
// Exploit score desc
const exploitDiff = (b.exploitScore ?? 0) - (a.exploitScore ?? 0);
if (exploitDiff !== 0) return exploitDiff;
// Reachability order (reachable > unknown > unreachable)
const reachOrder: Record<SearchReachability, number> = {
reachable: 2, unknown: 1, unreachable: 0,
};
const reachA = a.reachability ?? 'unknown';
const reachB = b.reachability ?? 'unknown';
const reachDiff = reachOrder[reachB] - reachOrder[reachA];
if (reachDiff !== 0) return reachDiff;
// Policy badge order (fail > warn > pass > waived)
const badgeOrder: Record<SearchPolicyBadge, number> = {
fail: 3, warn: 2, pass: 1, waived: 0,
};
const badgeA = a.policyBadge ?? 'pass';
const badgeB = b.policyBadge ?? 'pass';
const badgeDiff = badgeOrder[badgeB] - badgeOrder[badgeA];
if (badgeDiff !== 0) return badgeDiff;
// VEX state order (under_investigation > fixed > not_affected > unknown)
const vexOrder: Record<SearchVexState, number> = {
under_investigation: 3, fixed: 2, not_affected: 1, unknown: 0,
};
const vexA = a.vexState ?? 'unknown';
const vexB = b.vexState ?? 'unknown';
const vexDiff = vexOrder[vexB] - vexOrder[vexA];
if (vexDiff !== 0) return vexDiff;
// Secondary: advisoryId asc, then product asc
const advDiff = (a.advisoryId ?? '').localeCompare(b.advisoryId ?? '');
if (advDiff !== 0) return advDiff;
const prodDiff = (a.product ?? '').localeCompare(b.product ?? '');
if (prodDiff !== 0) return prodDiff;
// Final: findingId asc
return a.findingId.localeCompare(b.findingId);
}
/**
* Compute SHA-256 hash of sorted payload (simplified for client-side).
*/
function computePayloadHash(items: readonly SearchResultItem[]): string {
// Simplified: create deterministic string from sorted items
const payload = items.map(i => `${i.findingId}:${i.severity}:${i.exploitScore ?? 0}`).join('|');
// In production, use actual SHA-256; here we use a simple hash
let hash = 0;
for (let i = 0; i < payload.length; i++) {
const char = payload.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
return `sha256:${Math.abs(hash).toString(16).padStart(16, '0')}`;
}
/**
* HTTP Console Search Client.
* Implements WEB-CONSOLE-23-004 and WEB-CONSOLE-23-005.
*/
@Injectable({ providedIn: 'root' })
export class ConsoleSearchHttpClient implements ConsoleSearchApi {
constructor(
private readonly http: HttpClient,
private readonly authSession: AuthSessionStore,
private readonly tenantService: TenantActivationService,
@Inject(CONSOLE_API_BASE_URL) private readonly baseUrl: string
) {}
search(options: ConsoleSearchQueryOptions = {}): Observable<ConsoleSearchResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('console', 'read', ['console:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing console:read scope'));
}
const headers = this.buildHeaders(options);
const params = this.buildSearchParams(options);
return this.http.get<ConsoleSearchResponse>(`${this.baseUrl}/search`, { headers, params }).pipe(
map((response) => ({
...response,
traceId,
})),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getDownloads(options: ConsoleDownloadQueryOptions = {}): Observable<ConsoleDownloadResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('console', 'read', ['console:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing console:read scope'));
}
const headers = this.buildHeaders(options);
let params = new HttpParams();
if (options.format) {
params = params.set('format', options.format);
}
if (options.includeDsse) {
params = params.set('includeDsse', 'true');
}
return this.http.get<ConsoleDownloadResponse>(`${this.baseUrl}/downloads`, { headers, params }).pipe(
map((response) => ({
...response,
manifest: { ...response.manifest, traceId },
})),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getDownload(exportId: string, options: ConsoleDownloadQueryOptions = {}): Observable<ConsoleDownloadResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('console', 'read', ['console:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing console:read scope'));
}
const headers = this.buildHeaders(options);
let params = new HttpParams();
if (options.format) {
params = params.set('format', options.format);
}
if (options.includeDsse) {
params = params.set('includeDsse', 'true');
}
return this.http.get<ConsoleDownloadResponse>(
`${this.baseUrl}/downloads/${encodeURIComponent(exportId)}`,
{ headers, params }
).pipe(
map((response) => ({
...response,
manifest: { ...response.manifest, traceId },
})),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
private buildHeaders(opts: { tenantId?: string; traceId?: string; ifNoneMatch?: string }): HttpHeaders {
const tenant = this.resolveTenant(opts.tenantId);
const trace = opts.traceId ?? generateTraceId();
let headers = new HttpHeaders({
'X-StellaOps-Tenant': tenant,
'X-Stella-Trace-Id': trace,
'X-Stella-Request-Id': trace,
Accept: 'application/json',
});
if (opts.ifNoneMatch) {
headers = headers.set('If-None-Match', opts.ifNoneMatch);
}
return headers;
}
private buildSearchParams(opts: ConsoleSearchQueryOptions): HttpParams {
let params = new HttpParams();
if (opts.pageToken) {
params = params.set('pageToken', opts.pageToken);
}
if (opts.pageSize) {
params = params.set('pageSize', String(opts.pageSize));
}
if (opts.query) {
params = params.set('query', opts.query);
}
if (opts.severity?.length) {
params = params.set('severity', opts.severity.join(','));
}
if (opts.reachability?.length) {
params = params.set('reachability', opts.reachability.join(','));
}
if (opts.policyBadge?.length) {
params = params.set('policyBadge', opts.policyBadge.join(','));
}
if (opts.vexState?.length) {
params = params.set('vexState', opts.vexState.join(','));
}
if (opts.projectId) {
params = params.set('projectId', opts.projectId);
}
return params;
}
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
if (!tenant) {
throw new Error('ConsoleSearchClient requires an active tenant identifier.');
}
return tenant;
}
private mapError(err: unknown, traceId: string): Error {
if (err instanceof Error) {
return new Error(`[${traceId}] Console search error: ${err.message}`);
}
return new Error(`[${traceId}] Console search error: Unknown error`);
}
}
/**
* Mock Console Search API for quickstart mode.
* Implements WEB-CONSOLE-23-004 and WEB-CONSOLE-23-005.
*/
@Injectable({ providedIn: 'root' })
export class MockConsoleSearchClient implements ConsoleSearchApi {
private readonly mockResults: SearchResultItem[] = [
{
findingId: 'tenant-default:advisory-ai:sha256:9bf4',
advisoryId: 'CVE-2024-67890',
severity: 'critical',
exploitScore: 9.1,
reachability: 'reachable',
policyBadge: 'fail',
vexState: 'under_investigation',
product: 'registry.local/ops/transform:2025.10.0',
summary: 'lodash prototype pollution in _.set and related functions.',
lastUpdated: '2025-11-08T10:30:00Z',
},
{
findingId: 'tenant-default:advisory-ai:sha256:5d1a',
advisoryId: 'CVE-2024-12345',
severity: 'high',
exploitScore: 8.1,
reachability: 'reachable',
policyBadge: 'fail',
vexState: 'under_investigation',
product: 'registry.local/ops/auth:2025.10.0',
summary: 'jsonwebtoken <10.0.0 allows algorithm downgrade.',
lastUpdated: '2025-11-07T23:16:51Z',
},
{
findingId: 'tenant-default:advisory-ai:sha256:abc1',
advisoryId: 'CVE-2024-11111',
severity: 'medium',
exploitScore: 5.3,
reachability: 'unreachable',
policyBadge: 'warn',
vexState: 'not_affected',
product: 'registry.local/ops/gateway:2025.10.0',
summary: 'Express.js path traversal vulnerability.',
lastUpdated: '2025-11-06T14:00:00Z',
},
{
findingId: 'tenant-default:advisory-ai:sha256:def2',
advisoryId: 'CVE-2024-22222',
severity: 'low',
exploitScore: 3.0,
reachability: 'unknown',
policyBadge: 'pass',
vexState: 'fixed',
product: 'registry.local/ops/cache:2025.10.0',
summary: 'Cache timing side channel.',
lastUpdated: '2025-11-05T09:00:00Z',
},
];
search(options: ConsoleSearchQueryOptions = {}): Observable<ConsoleSearchResponse> {
const traceId = options.traceId ?? generateTraceId();
let filtered = [...this.mockResults];
// Apply filters
if (options.query) {
const queryLower = options.query.toLowerCase();
filtered = filtered.filter((r) =>
r.advisoryId.toLowerCase().includes(queryLower) ||
r.summary?.toLowerCase().includes(queryLower) ||
r.product?.toLowerCase().includes(queryLower)
);
}
if (options.severity?.length) {
filtered = filtered.filter((r) => options.severity!.includes(r.severity));
}
if (options.reachability?.length) {
filtered = filtered.filter((r) => r.reachability && options.reachability!.includes(r.reachability));
}
if (options.policyBadge?.length) {
filtered = filtered.filter((r) => r.policyBadge && options.policyBadge!.includes(r.policyBadge));
}
if (options.vexState?.length) {
filtered = filtered.filter((r) => r.vexState && options.vexState!.includes(r.vexState));
}
// Apply deterministic ranking
filtered.sort(compareSearchResults);
// Paginate
const pageSize = options.pageSize ?? 50;
const items = filtered.slice(0, pageSize);
// Compute ranking metadata
const payloadHash = computePayloadHash(items);
const newestUpdatedAt = items.reduce((newest, item) => {
if (!item.lastUpdated) return newest;
return !newest || item.lastUpdated > newest ? item.lastUpdated : newest;
}, '' as string);
const response: ConsoleSearchResponse = {
items,
ranking: {
sortKeys: ['severity', 'exploitScore', 'reachability', 'policyBadge', 'vexState', 'findingId'],
payloadHash,
newestUpdatedAt: newestUpdatedAt || undefined,
},
nextPageToken: filtered.length > pageSize ? this.createCursor(items[items.length - 1], traceId) : null,
total: filtered.length,
traceId,
etag: `"${payloadHash}"`,
cacheControl: 'public, max-age=300, stale-while-revalidate=60, stale-if-error=300',
};
return of(response).pipe(delay(50));
}
getDownloads(options: ConsoleDownloadQueryOptions = {}): Observable<ConsoleDownloadResponse> {
const traceId = options.traceId ?? generateTraceId();
const tenant = options.tenantId ?? 'tenant-default';
const exportId = `console-export::${tenant}::${new Date().toISOString().split('T')[0]}::0001`;
const manifest = this.createMockManifest(exportId, tenant, traceId, options.includeDsse);
return of({
manifest,
etag: `"${manifest.checksums.manifest}"`,
cacheControl: 'public, max-age=300, stale-while-revalidate=60, stale-if-error=300',
}).pipe(delay(50));
}
getDownload(exportId: string, options: ConsoleDownloadQueryOptions = {}): Observable<ConsoleDownloadResponse> {
const traceId = options.traceId ?? generateTraceId();
const tenant = options.tenantId ?? 'tenant-default';
const manifest = this.createMockManifest(exportId, tenant, traceId, options.includeDsse);
return of({
manifest,
etag: `"${manifest.checksums.manifest}"`,
cacheControl: 'public, max-age=300, stale-while-revalidate=60, stale-if-error=300',
}).pipe(delay(30));
}
private createMockManifest(
exportId: string,
tenantId: string,
traceId: string,
includeDsse?: boolean
): DownloadManifest {
const now = new Date();
const expiresAt = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days
// Sort items deterministically: type asc, id asc, format asc
const items: DownloadManifestItem[] = [
{
type: 'advisory',
id: 'CVE-2024-12345',
format: 'json',
url: `https://downloads.local/exports/${exportId}/advisory/CVE-2024-12345.json?sig=mock`,
sha256: 'sha256:a1b2c3d4e5f6',
size: 4096,
},
{
type: 'advisory',
id: 'CVE-2024-67890',
format: 'json',
url: `https://downloads.local/exports/${exportId}/advisory/CVE-2024-67890.json?sig=mock`,
sha256: 'sha256:f6e5d4c3b2a1',
size: 3072,
},
{
type: 'vex',
id: 'vex:tenant-default:jwt-auth:5d1a',
format: 'json',
url: `https://downloads.local/exports/${exportId}/vex/jwt-auth-5d1a.json?sig=mock`,
sha256: 'sha256:1a2b3c4d5e6f',
size: 2048,
},
{
type: 'vuln',
id: 'tenant-default:advisory-ai:sha256:5d1a',
format: 'json',
url: `https://downloads.local/exports/${exportId}/vuln/5d1a.json?sig=mock`,
sha256: 'sha256:6f5e4d3c2b1a',
size: 8192,
},
].sort((a, b) => {
const typeDiff = a.type.localeCompare(b.type);
if (typeDiff !== 0) return typeDiff;
const idDiff = a.id.localeCompare(b.id);
if (idDiff !== 0) return idDiff;
return a.format.localeCompare(b.format);
});
const manifestHash = `sha256:${Math.abs(exportId.split('').reduce((h, c) => ((h << 5) - h) + c.charCodeAt(0), 0)).toString(16).padStart(16, '0')}`;
return {
version: '2025-12-07',
exportId,
tenantId,
generatedAt: now.toISOString(),
items,
checksums: {
manifest: manifestHash,
bundle: `sha256:bundle${Date.now().toString(16)}`,
},
expiresAt: expiresAt.toISOString(),
dsseUrl: includeDsse ? `https://downloads.local/exports/${exportId}/manifest.dsse?sig=mock` : undefined,
traceId,
};
}
private createCursor(lastItem: SearchResultItem, tenantId: string): string {
// Create opaque, signed cursor with sortKeys and tenant
const cursorData = {
findingId: lastItem.findingId,
severity: lastItem.severity,
exploitScore: lastItem.exploitScore,
tenant: tenantId,
};
// In production, this would be signed and base64url encoded
return Buffer.from(JSON.stringify(cursorData)).toString('base64url');
}
}

View File

@@ -0,0 +1,134 @@
/**
* Console Search & Downloads Models.
* Implements WEB-CONSOLE-23-004 and WEB-CONSOLE-23-005.
*/
/** Severity levels for ranking. */
export type SearchSeverity = 'critical' | 'high' | 'medium' | 'low' | 'info' | 'unknown';
/** Policy badge for ranking. */
export type SearchPolicyBadge = 'fail' | 'warn' | 'pass' | 'waived';
/** Reachability status for ranking. */
export type SearchReachability = 'reachable' | 'unknown' | 'unreachable';
/** VEX state for ranking. */
export type SearchVexState = 'under_investigation' | 'fixed' | 'not_affected' | 'unknown';
/** Search result item base. */
export interface SearchResultItem {
readonly findingId: string;
readonly advisoryId: string;
readonly severity: SearchSeverity;
readonly exploitScore?: number;
readonly reachability?: SearchReachability;
readonly policyBadge?: SearchPolicyBadge;
readonly vexState?: SearchVexState;
readonly product?: string;
readonly summary?: string;
readonly lastUpdated?: string;
}
/** Search result ranking metadata. */
export interface SearchRankingMeta {
/** Sort keys used for deterministic ordering. */
readonly sortKeys: string[];
/** SHA-256 of sorted payload for ETag. */
readonly payloadHash: string;
/** Newest updatedAt in result set. */
readonly newestUpdatedAt?: string;
}
/** Paginated search response. */
export interface ConsoleSearchResponse {
readonly items: readonly SearchResultItem[];
readonly ranking: SearchRankingMeta;
readonly nextPageToken?: string | null;
readonly total: number;
readonly traceId?: string;
readonly etag?: string;
readonly cacheControl?: string;
}
/** Search query options. */
export interface ConsoleSearchQueryOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly pageToken?: string;
readonly pageSize?: number;
readonly query?: string;
readonly severity?: readonly SearchSeverity[];
readonly reachability?: readonly SearchReachability[];
readonly policyBadge?: readonly SearchPolicyBadge[];
readonly vexState?: readonly SearchVexState[];
readonly traceId?: string;
readonly ifNoneMatch?: string;
}
/** Download manifest item types. */
export type DownloadItemType = 'vuln' | 'advisory' | 'vex' | 'policy' | 'scan' | 'chart' | 'bundle';
/** Download manifest item. */
export interface DownloadManifestItem {
readonly type: DownloadItemType;
readonly id: string;
readonly format: string;
readonly url: string;
readonly sha256: string;
readonly size: number;
}
/** Download manifest checksums. */
export interface DownloadManifestChecksums {
readonly manifest: string;
readonly bundle?: string;
}
/** Download manifest structure. */
export interface DownloadManifest {
readonly version: string;
readonly exportId: string;
readonly tenantId: string;
readonly generatedAt: string;
readonly items: readonly DownloadManifestItem[];
readonly checksums: DownloadManifestChecksums;
readonly expiresAt: string;
/** Optional DSSE envelope URL. */
readonly dsseUrl?: string;
readonly traceId?: string;
}
/** Download response. */
export interface ConsoleDownloadResponse {
readonly manifest: DownloadManifest;
readonly etag?: string;
readonly cacheControl?: string;
}
/** Download query options. */
export interface ConsoleDownloadQueryOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly exportId?: string;
readonly format?: string;
readonly includeDsse?: boolean;
readonly traceId?: string;
readonly ifNoneMatch?: string;
}
/** Error codes for search/downloads. */
export type ConsoleSearchDownloadErrorCode =
| 'ERR_CONSOLE_DOWNLOAD_INVALID_CURSOR'
| 'ERR_CONSOLE_DOWNLOAD_EXPIRED'
| 'ERR_CONSOLE_DOWNLOAD_RATE_LIMIT'
| 'ERR_CONSOLE_DOWNLOAD_UNAVAILABLE'
| 'ERR_CONSOLE_SEARCH_INVALID_QUERY'
| 'ERR_CONSOLE_SEARCH_RATE_LIMIT';
/** Error response. */
export interface ConsoleSearchDownloadError {
readonly code: ConsoleSearchDownloadErrorCode;
readonly message: string;
readonly requestId: string;
readonly retryAfterSeconds?: number;
}

View File

@@ -0,0 +1,431 @@
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable, of, throwError, Subject } from 'rxjs';
import { map, catchError, delay } from 'rxjs/operators';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import {
CONSOLE_API_BASE_URL,
EVENT_SOURCE_FACTORY,
EventSourceFactory,
DEFAULT_EVENT_SOURCE_FACTORY,
} from './console-status.client';
import {
VexStatement,
VexStatementsResponse,
VexStatementsQueryOptions,
VexStatementDetail,
VexStreamEvent,
VexEventsQueryOptions,
VexStatus,
VexSourceType,
} from './console-vex.models';
import { generateTraceId } from './trace.util';
/**
* Console VEX API interface.
* Implements CONSOLE-VEX-30-001.
*/
export interface ConsoleVexApi {
/** List VEX statements with pagination and filters. */
listStatements(options?: VexStatementsQueryOptions): Observable<VexStatementsResponse>;
/** Get full VEX statement detail by ID. */
getStatement(statementId: string, options?: VexStatementsQueryOptions): Observable<VexStatementDetail>;
/** Subscribe to VEX events stream (SSE). */
streamEvents(options?: VexEventsQueryOptions): Observable<VexStreamEvent>;
}
export const CONSOLE_VEX_API = new InjectionToken<ConsoleVexApi>('CONSOLE_VEX_API');
/**
* HTTP Console VEX Client.
* Implements CONSOLE-VEX-30-001 with tenant scoping, RBAC, and SSE streaming.
*/
@Injectable({ providedIn: 'root' })
export class ConsoleVexHttpClient implements ConsoleVexApi {
constructor(
private readonly http: HttpClient,
private readonly authSession: AuthSessionStore,
private readonly tenantService: TenantActivationService,
@Inject(CONSOLE_API_BASE_URL) private readonly baseUrl: string,
@Inject(EVENT_SOURCE_FACTORY) private readonly eventSourceFactory: EventSourceFactory = DEFAULT_EVENT_SOURCE_FACTORY
) {}
listStatements(options: VexStatementsQueryOptions = {}): Observable<VexStatementsResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('console', 'read', ['console:read', 'vex:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing console:read or vex:read scope'));
}
const headers = this.buildHeaders(options);
const params = this.buildStatementsParams(options);
return this.http.get<VexStatementsResponse>(`${this.baseUrl}/vex/statements`, { headers, params }).pipe(
map((response) => ({
...response,
traceId,
})),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getStatement(statementId: string, options: VexStatementsQueryOptions = {}): Observable<VexStatementDetail> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('console', 'read', ['console:read', 'vex:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing console:read or vex:read scope'));
}
const headers = this.buildHeaders(options);
return this.http.get<VexStatementDetail>(
`${this.baseUrl}/vex/statements/${encodeURIComponent(statementId)}`,
{ headers }
).pipe(
map((response) => ({
...response,
traceId,
})),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
streamEvents(options: VexEventsQueryOptions = {}): Observable<VexStreamEvent> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
let url = `${this.baseUrl}/vex/events?tenant=${encodeURIComponent(tenant)}&traceId=${encodeURIComponent(traceId)}`;
if (options.projectId) {
url += `&projectId=${encodeURIComponent(options.projectId)}`;
}
return new Observable<VexStreamEvent>((observer) => {
const eventSource = this.eventSourceFactory(url);
// Set Last-Event-ID header for replay support
if (options.lastEventId && 'lastEventId' in eventSource) {
// Note: EventSource doesn't allow setting headers directly,
// so we include lastEventId as query param instead
url += `&lastEventId=${encodeURIComponent(options.lastEventId)}`;
}
const handleEvent = (eventType: string) => (event: MessageEvent) => {
try {
const data = JSON.parse(event.data);
observer.next({
event: eventType as VexStreamEvent['event'],
...data,
traceId,
});
} catch (err) {
// Skip invalid JSON (e.g., keepalive with empty data)
if (eventType === 'keepalive') {
observer.next({
event: 'keepalive',
sequence: Date.now(),
traceId,
});
}
}
};
eventSource.addEventListener('statement.created', handleEvent('statement.created'));
eventSource.addEventListener('statement.updated', handleEvent('statement.updated'));
eventSource.addEventListener('statement.deleted', handleEvent('statement.deleted'));
eventSource.addEventListener('statement.conflict', handleEvent('statement.conflict'));
eventSource.addEventListener('keepalive', handleEvent('keepalive'));
eventSource.onmessage = (event) => {
try {
const parsed = JSON.parse(event.data) as VexStreamEvent;
observer.next({ ...parsed, traceId });
} catch {
// Ignore parse errors for default messages
}
};
eventSource.onerror = (err) => {
observer.error(new Error(`[${traceId}] VEX events stream error`));
eventSource.close();
};
return () => {
eventSource.close();
};
});
}
private buildHeaders(opts: { tenantId?: string; traceId?: string; ifNoneMatch?: string }): HttpHeaders {
const tenant = this.resolveTenant(opts.tenantId);
const trace = opts.traceId ?? generateTraceId();
let headers = new HttpHeaders({
'X-StellaOps-Tenant': tenant,
'X-Stella-Trace-Id': trace,
'X-Stella-Request-Id': trace,
Accept: 'application/json',
});
if (opts.ifNoneMatch) {
headers = headers.set('If-None-Match', opts.ifNoneMatch);
}
return headers;
}
private buildStatementsParams(opts: VexStatementsQueryOptions): HttpParams {
let params = new HttpParams();
if (opts.pageToken) {
params = params.set('pageToken', opts.pageToken);
}
if (opts.pageSize) {
params = params.set('pageSize', String(opts.pageSize));
}
if (opts.advisoryId?.length) {
params = params.set('advisoryId', opts.advisoryId.join(','));
}
if (opts.justification?.length) {
params = params.set('justification', opts.justification.join(','));
}
if (opts.statementType?.length) {
params = params.set('statementType', opts.statementType.join(','));
}
if (opts.search) {
params = params.set('search', opts.search);
}
if (opts.projectId) {
params = params.set('projectId', opts.projectId);
}
if (opts.prefer) {
params = params.set('prefer', opts.prefer);
}
return params;
}
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
if (!tenant) {
throw new Error('ConsoleVexClient requires an active tenant identifier.');
}
return tenant;
}
private mapError(err: unknown, traceId: string): Error {
if (err instanceof Error) {
return new Error(`[${traceId}] Console VEX error: ${err.message}`);
}
return new Error(`[${traceId}] Console VEX error: Unknown error`);
}
}
/**
* Mock Console VEX API for quickstart mode.
* Implements CONSOLE-VEX-30-001.
*/
@Injectable({ providedIn: 'root' })
export class MockConsoleVexClient implements ConsoleVexApi {
private readonly eventSubject = new Subject<VexStreamEvent>();
private eventSequence = 1000;
private readonly mockStatements: VexStatement[] = [
{
statementId: 'vex:tenant-default:jwt-auth:5d1a',
advisoryId: 'CVE-2024-12345',
product: 'registry.local/ops/auth:2025.10.0',
status: 'under_investigation',
justification: 'exploit_observed',
lastUpdated: '2025-11-07T23:10:09Z',
source: {
type: 'advisory_ai',
modelBuild: 'aiai-console-2025-10-28',
confidence: 0.74,
},
links: [
{
rel: 'finding',
href: '/console/vuln/findings/tenant-default:advisory-ai:sha256:5d1a',
},
],
},
{
statementId: 'vex:tenant-default:data-transform:9bf4',
advisoryId: 'CVE-2024-67890',
product: 'registry.local/ops/transform:2025.10.0',
status: 'affected',
justification: 'exploit_observed',
lastUpdated: '2025-11-08T10:30:00Z',
source: {
type: 'vex',
confidence: 0.95,
},
links: [
{
rel: 'finding',
href: '/console/vuln/findings/tenant-default:advisory-ai:sha256:9bf4',
},
],
},
{
statementId: 'vex:tenant-default:api-gateway:abc1',
advisoryId: 'CVE-2024-11111',
product: 'registry.local/ops/gateway:2025.10.0',
status: 'not_affected',
justification: 'inline_mitigations_exist',
lastUpdated: '2025-11-06T14:00:00Z',
source: {
type: 'custom',
confidence: 1.0,
},
},
{
statementId: 'vex:tenant-default:cache:def2',
advisoryId: 'CVE-2024-22222',
product: 'registry.local/ops/cache:2025.10.0',
status: 'fixed',
justification: 'solution_available',
lastUpdated: '2025-11-05T09:00:00Z',
source: {
type: 'openvex',
confidence: 1.0,
},
},
];
listStatements(options: VexStatementsQueryOptions = {}): Observable<VexStatementsResponse> {
const traceId = options.traceId ?? generateTraceId();
let filtered = [...this.mockStatements];
// Apply filters
if (options.advisoryId?.length) {
filtered = filtered.filter((s) => options.advisoryId!.includes(s.advisoryId));
}
if (options.justification?.length) {
filtered = filtered.filter((s) => s.justification && options.justification!.includes(s.justification));
}
if (options.statementType?.length) {
filtered = filtered.filter((s) => s.source && options.statementType!.includes(s.source.type));
}
if (options.search) {
const searchLower = options.search.toLowerCase();
filtered = filtered.filter((s) =>
s.advisoryId.toLowerCase().includes(searchLower) ||
s.product.toLowerCase().includes(searchLower)
);
}
// Sort: lastUpdated desc, statementId asc
filtered.sort((a, b) => {
const dateDiff = new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime();
if (dateDiff !== 0) return dateDiff;
return a.statementId.localeCompare(b.statementId);
});
// Paginate
const pageSize = options.pageSize ?? 50;
const items = filtered.slice(0, pageSize);
const response: VexStatementsResponse = {
items,
nextPageToken: filtered.length > pageSize ? 'mock-next-page' : null,
total: filtered.length,
traceId,
};
return of(response).pipe(delay(50));
}
getStatement(statementId: string, options: VexStatementsQueryOptions = {}): Observable<VexStatementDetail> {
const traceId = options.traceId ?? generateTraceId();
const statement = this.mockStatements.find((s) => s.statementId === statementId);
if (!statement) {
return throwError(() => new Error(`Statement ${statementId} not found`));
}
const detail: VexStatementDetail = {
...statement,
provenance: {
documentId: `tenant-default:vex:${statementId}`,
observationPath: '/statements/0',
recordedAt: statement.lastUpdated,
},
impactStatement: 'Service may be impacted until remediation is applied.',
remediations: [
{
type: 'patch',
description: 'Upgrade to the latest patched version.',
deadline: '2025-12-15T00:00:00Z',
},
],
etag: `"vex-${statementId}-${Date.now()}"`,
traceId,
};
return of(detail).pipe(delay(30));
}
streamEvents(options: VexEventsQueryOptions = {}): Observable<VexStreamEvent> {
const traceId = options.traceId ?? generateTraceId();
// Return observable that emits events
return new Observable<VexStreamEvent>((observer) => {
// Subscribe to internal subject
const subscription = this.eventSubject.subscribe((event) => {
observer.next({ ...event, traceId });
});
// Send initial keepalive
observer.next({
event: 'keepalive',
sequence: this.eventSequence++,
traceId,
});
// Emit mock events periodically for testing
const interval = setInterval(() => {
observer.next({
event: 'keepalive',
sequence: this.eventSequence++,
traceId,
});
}, 15000); // Every 15 seconds
return () => {
subscription.unsubscribe();
clearInterval(interval);
};
});
}
/** Trigger a mock event for testing. */
triggerMockEvent(event: Omit<VexStreamEvent, 'sequence'>): void {
this.eventSubject.next({
...event,
sequence: this.eventSequence++,
});
}
/** Simulate a statement update event. */
simulateStatementUpdate(statementId: string, newStatus: VexStatus): void {
const statement = this.mockStatements.find((s) => s.statementId === statementId);
if (statement) {
this.eventSubject.next({
event: 'statement.updated',
statementId,
advisoryId: statement.advisoryId,
product: statement.product,
state: newStatus,
sequence: this.eventSequence++,
updatedAt: new Date().toISOString(),
});
}
}
}

View File

@@ -0,0 +1,136 @@
/**
* Console VEX Workspace Models.
* Implements CONSOLE-VEX-30-001.
*/
/** VEX status values. */
export type VexStatus =
| 'not_affected'
| 'fixed'
| 'under_investigation'
| 'affected'
| 'unknown'
| 'unavailable';
/** VEX justification values. */
export type VexJustification =
| 'exploit_observed'
| 'component_not_present'
| 'vulnerable_code_not_present'
| 'vulnerable_code_not_in_execute_path'
| 'inline_mitigations_exist'
| 'vulnerable_code_cannot_be_controlled_by_adversary'
| 'solution_available'
| 'workaround_available'
| 'no_impact'
| 'unknown';
/** VEX statement source type. */
export type VexSourceType = 'vex' | 'openvex' | 'custom' | 'advisory_ai';
/** VEX statement source. */
export interface VexStatementSource {
readonly type: VexSourceType;
readonly modelBuild?: string;
readonly confidence?: number;
}
/** Related link in VEX statement. */
export interface VexStatementLink {
readonly rel: string;
readonly href: string;
}
/** VEX statement item. */
export interface VexStatement {
readonly statementId: string;
readonly advisoryId: string;
readonly product: string;
readonly status: VexStatus;
readonly justification?: VexJustification | string;
readonly lastUpdated: string;
readonly source?: VexStatementSource;
readonly links?: readonly VexStatementLink[];
}
/** VEX statement conflict info. */
export interface VexConflict {
readonly conflictId: string;
readonly statementIds: readonly string[];
readonly conflictType: string;
readonly summary: string;
readonly resolvedAt?: string;
}
/** Paginated VEX statements response. */
export interface VexStatementsResponse {
readonly items: readonly VexStatement[];
readonly conflicts?: readonly VexConflict[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Query options for VEX statements. */
export interface VexStatementsQueryOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly pageToken?: string;
readonly pageSize?: number;
readonly advisoryId?: readonly string[];
readonly justification?: readonly string[];
readonly statementType?: readonly VexSourceType[];
readonly search?: string;
readonly prefer?: 'json' | 'stream';
readonly traceId?: string;
readonly ifNoneMatch?: string;
}
/** Full VEX statement detail. */
export interface VexStatementDetail extends VexStatement {
readonly provenance?: {
readonly documentId: string;
readonly observationPath?: string;
readonly recordedAt: string;
};
readonly impactStatement?: string;
readonly remediations?: readonly {
readonly type: string;
readonly description: string;
readonly deadline?: string;
}[];
readonly etag?: string;
readonly traceId?: string;
}
/** SSE event types for VEX workspace. */
export type VexEventType =
| 'statement.created'
| 'statement.updated'
| 'statement.deleted'
| 'statement.conflict'
| 'keepalive';
/** VEX SSE event payload. */
export interface VexStreamEvent {
readonly event: VexEventType;
readonly statementId?: string;
readonly advisoryId?: string;
readonly product?: string;
readonly state?: VexStatus;
readonly justification?: string;
readonly severityHint?: string;
readonly policyBadge?: string;
readonly conflictSummary?: string;
readonly sequence: number;
readonly updatedAt?: string;
readonly traceId?: string;
}
/** Query options for VEX events stream. */
export interface VexEventsQueryOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly lastEventId?: string;
readonly traceId?: string;
}

View File

@@ -0,0 +1,482 @@
import { HttpClient, HttpHeaders, HttpParams, HttpResponse } from '@angular/common/http';
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { map, catchError, delay } from 'rxjs/operators';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import { CONSOLE_API_BASE_URL } from './console-status.client';
import {
VulnFinding,
VulnFindingsResponse,
VulnFindingsQueryOptions,
VulnFindingDetail,
VulnFindingQueryOptions,
VulnFacets,
VulnTicketRequest,
VulnTicketResponse,
VulnSeverity,
PolicyBadge,
VexState,
ReachabilityStatus,
} from './console-vuln.models';
import { generateTraceId } from './trace.util';
/**
* Console Vuln API interface.
* Implements CONSOLE-VULN-29-001.
*/
export interface ConsoleVulnApi {
/** List findings with pagination and filters. */
listFindings(options?: VulnFindingsQueryOptions): Observable<VulnFindingsResponse>;
/** Get facets for sidebar filters. */
getFacets(options?: VulnFindingsQueryOptions): Observable<VulnFacets>;
/** Get full finding detail by ID. */
getFinding(findingId: string, options?: VulnFindingQueryOptions): Observable<VulnFindingDetail>;
/** Export findings to ticketing system. */
createTicket(request: VulnTicketRequest, options?: VulnFindingQueryOptions): Observable<VulnTicketResponse>;
}
export const CONSOLE_VULN_API = new InjectionToken<ConsoleVulnApi>('CONSOLE_VULN_API');
/**
* HTTP Console Vuln Client.
* Implements CONSOLE-VULN-29-001 with tenant scoping and RBAC.
*/
@Injectable({ providedIn: 'root' })
export class ConsoleVulnHttpClient implements ConsoleVulnApi {
constructor(
private readonly http: HttpClient,
private readonly authSession: AuthSessionStore,
private readonly tenantService: TenantActivationService,
@Inject(CONSOLE_API_BASE_URL) private readonly baseUrl: string
) {}
listFindings(options: VulnFindingsQueryOptions = {}): Observable<VulnFindingsResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('console', 'read', ['console:read', 'vuln:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing console:read or vuln:read scope'));
}
const headers = this.buildHeaders(options);
const params = this.buildFindingsParams(options);
return this.http.get<VulnFindingsResponse>(`${this.baseUrl}/vuln/findings`, { headers, params }).pipe(
map((response) => ({
...response,
traceId,
})),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getFacets(options: VulnFindingsQueryOptions = {}): Observable<VulnFacets> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('console', 'read', ['console:read', 'vuln:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing console:read or vuln:read scope'));
}
const headers = this.buildHeaders(options);
const params = this.buildFindingsParams(options);
return this.http.get<VulnFacets>(`${this.baseUrl}/vuln/facets`, { headers, params }).pipe(
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getFinding(findingId: string, options: VulnFindingQueryOptions = {}): Observable<VulnFindingDetail> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('console', 'read', ['console:read', 'vuln:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing console:read or vuln:read scope'));
}
const headers = this.buildHeaders(options);
return this.http.get<VulnFindingDetail>(
`${this.baseUrl}/vuln/${encodeURIComponent(findingId)}`,
{ headers }
).pipe(
map((response) => ({
...response,
traceId,
})),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
createTicket(request: VulnTicketRequest, options: VulnFindingQueryOptions = {}): Observable<VulnTicketResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('console', 'write', ['console:read', 'vuln:read', 'console:export'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing console:export scope'));
}
const headers = this.buildHeaders(options);
return this.http.post<VulnTicketResponse>(`${this.baseUrl}/vuln/tickets`, request, { headers }).pipe(
map((response) => ({
...response,
traceId,
})),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
private buildHeaders(opts: { tenantId?: string; traceId?: string; ifNoneMatch?: string }): HttpHeaders {
const tenant = this.resolveTenant(opts.tenantId);
const trace = opts.traceId ?? generateTraceId();
let headers = new HttpHeaders({
'X-StellaOps-Tenant': tenant,
'X-Stella-Trace-Id': trace,
'X-Stella-Request-Id': trace,
Accept: 'application/json',
});
if (opts.ifNoneMatch) {
headers = headers.set('If-None-Match', opts.ifNoneMatch);
}
return headers;
}
private buildFindingsParams(opts: VulnFindingsQueryOptions): HttpParams {
let params = new HttpParams();
if (opts.pageToken) {
params = params.set('pageToken', opts.pageToken);
}
if (opts.pageSize) {
params = params.set('pageSize', String(opts.pageSize));
}
if (opts.severity?.length) {
params = params.set('severity', opts.severity.join(','));
}
if (opts.product?.length) {
params = params.set('product', opts.product.join(','));
}
if (opts.policyBadge?.length) {
params = params.set('policyBadge', opts.policyBadge.join(','));
}
if (opts.vexState?.length) {
params = params.set('vexState', opts.vexState.join(','));
}
if (opts.reachability?.length) {
params = params.set('reachability', opts.reachability.join(','));
}
if (opts.search) {
params = params.set('search', opts.search);
}
if (opts.projectId) {
params = params.set('projectId', opts.projectId);
}
return params;
}
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
if (!tenant) {
throw new Error('ConsoleVulnClient requires an active tenant identifier.');
}
return tenant;
}
private mapError(err: unknown, traceId: string): Error {
if (err instanceof Error) {
return new Error(`[${traceId}] Console vuln error: ${err.message}`);
}
return new Error(`[${traceId}] Console vuln error: Unknown error`);
}
}
/**
* Mock Console Vuln API for quickstart mode.
* Implements CONSOLE-VULN-29-001.
*/
@Injectable({ providedIn: 'root' })
export class MockConsoleVulnClient implements ConsoleVulnApi {
private readonly mockFindings: VulnFinding[] = [
{
findingId: 'tenant-default:advisory-ai:sha256:5d1a',
coordinates: {
advisoryId: 'CVE-2024-12345',
package: 'pkg:npm/jsonwebtoken@9.0.2',
component: 'jwt-auth-service',
image: 'registry.local/ops/auth:2025.10.0',
},
summary: 'jsonwebtoken <10.0.0 allows algorithm downgrade.',
severity: 'high',
cvss: 8.1,
kev: true,
policyBadge: 'fail',
vex: {
statementId: 'vex:tenant-default:jwt-auth:5d1a',
state: 'under_investigation',
justification: 'Advisory AI flagged reachable path via Scheduler run 42.',
},
reachability: {
status: 'reachable',
lastObserved: '2025-11-07T23:11:04Z',
signalsVersion: 'signals-2025.310.1',
},
evidence: {
sbomDigest: 'sha256:6c81a92f',
policyRunId: 'policy-run::2025-11-07::ca9f',
attestationId: 'dsse://authority/attest/84a2',
},
timestamps: {
firstSeen: '2025-10-31T04:22:18Z',
lastSeen: '2025-11-07T23:16:51Z',
},
},
{
findingId: 'tenant-default:advisory-ai:sha256:9bf4',
coordinates: {
advisoryId: 'CVE-2024-67890',
package: 'pkg:npm/lodash@4.17.20',
component: 'data-transform',
image: 'registry.local/ops/transform:2025.10.0',
},
summary: 'lodash prototype pollution in _.set and related functions.',
severity: 'critical',
cvss: 9.1,
kev: false,
policyBadge: 'fail',
vex: {
statementId: 'vex:tenant-default:data-transform:9bf4',
state: 'affected',
justification: 'Confirmed vulnerable path in production.',
},
reachability: {
status: 'reachable',
lastObserved: '2025-11-08T10:30:00Z',
signalsVersion: 'signals-2025.310.1',
},
timestamps: {
firstSeen: '2025-10-15T08:00:00Z',
lastSeen: '2025-11-08T10:30:00Z',
},
},
{
findingId: 'tenant-default:advisory-ai:sha256:abc1',
coordinates: {
advisoryId: 'CVE-2024-11111',
package: 'pkg:npm/express@4.18.1',
component: 'api-gateway',
image: 'registry.local/ops/gateway:2025.10.0',
},
summary: 'Express.js path traversal vulnerability.',
severity: 'medium',
cvss: 5.3,
kev: false,
policyBadge: 'warn',
vex: {
statementId: 'vex:tenant-default:api-gateway:abc1',
state: 'not_affected',
justification: 'Mitigation applied via WAF rules.',
},
reachability: {
status: 'unreachable',
lastObserved: '2025-11-06T14:00:00Z',
signalsVersion: 'signals-2025.310.1',
},
timestamps: {
firstSeen: '2025-09-20T12:00:00Z',
lastSeen: '2025-11-06T14:00:00Z',
},
},
];
listFindings(options: VulnFindingsQueryOptions = {}): Observable<VulnFindingsResponse> {
const traceId = options.traceId ?? generateTraceId();
let filtered = [...this.mockFindings];
// Apply filters
if (options.severity?.length) {
filtered = filtered.filter((f) => options.severity!.includes(f.severity));
}
if (options.policyBadge?.length) {
filtered = filtered.filter((f) => options.policyBadge!.includes(f.policyBadge));
}
if (options.reachability?.length) {
filtered = filtered.filter((f) => f.reachability && options.reachability!.includes(f.reachability.status));
}
if (options.vexState?.length) {
filtered = filtered.filter((f) => f.vex && options.vexState!.includes(f.vex.state));
}
if (options.search) {
const searchLower = options.search.toLowerCase();
filtered = filtered.filter((f) =>
f.coordinates.advisoryId.toLowerCase().includes(searchLower) ||
f.summary.toLowerCase().includes(searchLower)
);
}
// Sort: severity desc, cvss desc, findingId asc
const severityOrder: Record<VulnSeverity, number> = {
critical: 5, high: 4, medium: 3, low: 2, info: 1, unknown: 0,
};
filtered.sort((a, b) => {
const sevDiff = severityOrder[b.severity] - severityOrder[a.severity];
if (sevDiff !== 0) return sevDiff;
const cvssDiff = (b.cvss ?? 0) - (a.cvss ?? 0);
if (cvssDiff !== 0) return cvssDiff;
return a.findingId.localeCompare(b.findingId);
});
// Paginate
const pageSize = options.pageSize ?? 50;
const items = filtered.slice(0, pageSize);
const response: VulnFindingsResponse = {
items,
facets: this.computeFacets(this.mockFindings),
nextPageToken: filtered.length > pageSize ? 'mock-next-page' : null,
total: filtered.length,
traceId,
};
return of(response).pipe(delay(50));
}
getFacets(options: VulnFindingsQueryOptions = {}): Observable<VulnFacets> {
return of(this.computeFacets(this.mockFindings)).pipe(delay(25));
}
getFinding(findingId: string, options: VulnFindingQueryOptions = {}): Observable<VulnFindingDetail> {
const traceId = options.traceId ?? generateTraceId();
const finding = this.mockFindings.find((f) => f.findingId === findingId);
if (!finding) {
return throwError(() => new Error(`Finding ${findingId} not found`));
}
const detail: VulnFindingDetail = {
findingId: finding.findingId,
details: {
description: finding.summary,
references: [
`https://nvd.nist.gov/vuln/detail/${finding.coordinates.advisoryId}`,
'https://github.com/security/advisories',
],
exploitAvailability: finding.kev ? 'known_exploit' : 'unknown',
},
policyBadges: [
{
policyId: 'policy://tenant-default/runtime-hardening',
verdict: finding.policyBadge,
explainUrl: `/policy/runs/${finding.evidence?.policyRunId ?? 'unknown'}`,
},
],
vex: finding.vex ? {
statementId: finding.vex.statementId,
state: finding.vex.state,
justification: finding.vex.justification,
impactStatement: 'Service remains exposed until patch applied.',
remediations: [
{
type: 'patch',
description: `Upgrade ${finding.coordinates.package} to latest version.`,
deadline: '2025-12-15T00:00:00Z',
},
],
} : undefined,
reachability: finding.reachability ? {
status: finding.reachability.status,
callPathSamples: ['api-gateway -> service -> vulnerable-function'],
lastUpdated: finding.reachability.lastObserved,
} : undefined,
evidence: {
sbom: finding.evidence?.sbomDigest ? {
digest: finding.evidence.sbomDigest,
componentPath: ['/package.json', '/node_modules/' + finding.coordinates.package.split('@')[0].replace('pkg:npm/', '')],
} : undefined,
attestations: finding.evidence?.attestationId ? [
{
type: 'scan-report',
attestationId: finding.evidence.attestationId,
signer: 'attestor@stella-ops.org',
bundleDigest: 'sha256:e2bb1234',
},
] : undefined,
},
timestamps: finding.timestamps ? {
firstSeen: finding.timestamps.firstSeen,
lastSeen: finding.timestamps.lastSeen,
vexLastUpdated: '2025-11-07T23:10:09Z',
} : undefined,
traceId,
etag: `"finding-${findingId}-${Date.now()}"`,
};
return of(detail).pipe(delay(30));
}
createTicket(request: VulnTicketRequest, options: VulnFindingQueryOptions = {}): Observable<VulnTicketResponse> {
const traceId = options.traceId ?? generateTraceId();
const ticketId = `console-ticket::${request.tenant}::${new Date().toISOString().split('T')[0]}::${String(Date.now()).slice(-5)}`;
const response: VulnTicketResponse = {
ticketId,
payload: {
version: '2025-12-01',
tenant: request.tenant,
findings: request.selection.map((id) => {
const finding = this.mockFindings.find((f) => f.findingId === id);
return {
findingId: id,
severity: finding?.severity ?? 'unknown',
};
}),
policyBadge: 'fail',
vexSummary: `${request.selection.length} findings pending review.`,
attachments: [
{
type: 'json',
name: `console-ticket-${ticketId}.json`,
digest: 'sha256:mock1234',
contentType: 'application/json',
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
},
],
},
auditEventId: `console.ticket.export::${ticketId}`,
traceId,
};
return of(response).pipe(delay(100));
}
private computeFacets(findings: VulnFinding[]): VulnFacets {
const severityCounts: Record<string, number> = {};
const policyBadgeCounts: Record<string, number> = {};
const reachabilityCounts: Record<string, number> = {};
const vexStateCounts: Record<string, number> = {};
for (const f of findings) {
severityCounts[f.severity] = (severityCounts[f.severity] ?? 0) + 1;
policyBadgeCounts[f.policyBadge] = (policyBadgeCounts[f.policyBadge] ?? 0) + 1;
if (f.reachability) {
reachabilityCounts[f.reachability.status] = (reachabilityCounts[f.reachability.status] ?? 0) + 1;
}
if (f.vex) {
vexStateCounts[f.vex.state] = (vexStateCounts[f.vex.state] ?? 0) + 1;
}
}
return {
severity: Object.entries(severityCounts).map(([value, count]) => ({ value, count })),
policyBadge: Object.entries(policyBadgeCounts).map(([value, count]) => ({ value, count })),
reachability: Object.entries(reachabilityCounts).map(([value, count]) => ({ value, count })),
vexState: Object.entries(vexStateCounts).map(([value, count]) => ({ value, count })),
};
}
}

View File

@@ -0,0 +1,232 @@
/**
* Console Vuln Workspace Models.
* Implements CONSOLE-VULN-29-001.
*/
/** Severity levels. */
export type VulnSeverity = 'critical' | 'high' | 'medium' | 'low' | 'info' | 'unknown';
/** Policy verdict badges. */
export type PolicyBadge = 'pass' | 'warn' | 'fail' | 'waived';
/** VEX state values. */
export type VexState =
| 'not_affected'
| 'fixed'
| 'under_investigation'
| 'affected'
| 'unknown'
| 'unavailable';
/** Reachability status. */
export type ReachabilityStatus = 'reachable' | 'unreachable' | 'unknown';
/** Finding coordinates. */
export interface FindingCoordinates {
readonly advisoryId: string;
readonly package: string;
readonly component?: string;
readonly image?: string;
}
/** VEX summary in finding. */
export interface FindingVex {
readonly statementId: string;
readonly state: VexState;
readonly justification?: string;
}
/** Reachability info in finding. */
export interface FindingReachability {
readonly status: ReachabilityStatus;
readonly lastObserved?: string;
readonly signalsVersion?: string;
}
/** Evidence links in finding. */
export interface FindingEvidence {
readonly sbomDigest?: string;
readonly policyRunId?: string;
readonly attestationId?: string;
}
/** Finding timestamps. */
export interface FindingTimestamps {
readonly firstSeen: string;
readonly lastSeen: string;
}
/** Vulnerability finding item. */
export interface VulnFinding {
readonly findingId: string;
readonly coordinates: FindingCoordinates;
readonly summary: string;
readonly severity: VulnSeverity;
readonly cvss?: number;
readonly kev?: boolean;
readonly policyBadge: PolicyBadge;
readonly vex?: FindingVex;
readonly reachability?: FindingReachability;
readonly evidence?: FindingEvidence;
readonly timestamps?: FindingTimestamps;
}
/** Facet value with count. */
export interface FacetValue {
readonly value: string;
readonly count: number;
}
/** Facets for sidebar filters. */
export interface VulnFacets {
readonly severity?: readonly FacetValue[];
readonly policyBadge?: readonly FacetValue[];
readonly reachability?: readonly FacetValue[];
readonly vexState?: readonly FacetValue[];
readonly product?: readonly FacetValue[];
}
/** Paginated findings response. */
export interface VulnFindingsResponse {
readonly items: readonly VulnFinding[];
readonly facets?: VulnFacets;
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Query options for findings. */
export interface VulnFindingsQueryOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly pageToken?: string;
readonly pageSize?: number;
readonly severity?: readonly VulnSeverity[];
readonly product?: readonly string[];
readonly policyBadge?: readonly PolicyBadge[];
readonly vexState?: readonly VexState[];
readonly reachability?: readonly ReachabilityStatus[];
readonly search?: string;
readonly traceId?: string;
readonly ifNoneMatch?: string;
}
/** Policy badge detail. */
export interface PolicyBadgeDetail {
readonly policyId: string;
readonly verdict: PolicyBadge;
readonly explainUrl?: string;
}
/** Remediation entry. */
export interface Remediation {
readonly type: string;
readonly description: string;
readonly deadline?: string;
}
/** Full VEX info for detail view. */
export interface FindingVexDetail {
readonly statementId: string;
readonly state: VexState;
readonly justification?: string;
readonly impactStatement?: string;
readonly remediations?: readonly Remediation[];
}
/** Reachability detail. */
export interface FindingReachabilityDetail {
readonly status: ReachabilityStatus;
readonly callPathSamples?: readonly string[];
readonly lastUpdated?: string;
}
/** SBOM evidence. */
export interface SbomEvidence {
readonly digest: string;
readonly componentPath?: readonly string[];
}
/** Attestation entry. */
export interface AttestationEvidence {
readonly type: string;
readonly attestationId: string;
readonly signer?: string;
readonly bundleDigest?: string;
}
/** Full evidence for detail view. */
export interface FindingEvidenceDetail {
readonly sbom?: SbomEvidence;
readonly attestations?: readonly AttestationEvidence[];
}
/** Finding details payload. */
export interface FindingDetails {
readonly description?: string;
readonly references?: readonly string[];
readonly exploitAvailability?: string;
}
/** Finding timestamps for detail view. */
export interface FindingTimestampsDetail {
readonly firstSeen: string;
readonly lastSeen: string;
readonly vexLastUpdated?: string;
}
/** Full finding detail response. */
export interface VulnFindingDetail {
readonly findingId: string;
readonly details?: FindingDetails;
readonly policyBadges?: readonly PolicyBadgeDetail[];
readonly vex?: FindingVexDetail;
readonly reachability?: FindingReachabilityDetail;
readonly evidence?: FindingEvidenceDetail;
readonly timestamps?: FindingTimestampsDetail;
readonly traceId?: string;
readonly etag?: string;
}
/** Query options for finding detail. */
export interface VulnFindingQueryOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly traceId?: string;
readonly ifNoneMatch?: string;
}
/** Ticket export request. */
export interface VulnTicketRequest {
readonly tenant: string;
readonly selection: readonly string[];
readonly targetSystem: string;
readonly metadata?: Record<string, unknown>;
}
/** Ticket attachment. */
export interface TicketAttachment {
readonly type: string;
readonly name: string;
readonly digest: string;
readonly contentType: string;
readonly expiresAt?: string;
}
/** Ticket payload. */
export interface TicketPayload {
readonly version: string;
readonly tenant: string;
readonly findings: readonly { findingId: string; severity: string }[];
readonly policyBadge?: string;
readonly vexSummary?: string;
readonly attachments?: readonly TicketAttachment[];
}
/** Ticket response. */
export interface VulnTicketResponse {
readonly ticketId: string;
readonly payload: TicketPayload;
readonly auditEventId: string;
readonly traceId?: string;
}

View File

@@ -0,0 +1,369 @@
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { map, catchError, delay } from 'rxjs/operators';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import {
EVENT_SOURCE_FACTORY,
EventSourceFactory,
DEFAULT_EVENT_SOURCE_FACTORY,
} from './console-status.client';
import {
ExportProfile,
ExportProfilesResponse,
ExportProfilesQueryOptions,
ExportRunRequest,
ExportRunResponse,
ExportRunQueryOptions,
ExportRunEvent,
DistributionResponse,
ExportRunStatus,
ExportTargetType,
ExportFormat,
} from './export-center.models';
import { generateTraceId } from './trace.util';
export const EXPORT_CENTER_API_BASE_URL = new InjectionToken<string>('EXPORT_CENTER_API_BASE_URL');
/**
* Export Center API interface.
* Implements WEB-EXPORT-35-001, WEB-EXPORT-36-001, WEB-EXPORT-37-001.
*/
export interface ExportCenterApi {
/** List export profiles. */
listProfiles(options?: ExportProfilesQueryOptions): Observable<ExportProfilesResponse>;
/** Start an export run. */
startRun(request: ExportRunRequest, options?: ExportRunQueryOptions): Observable<ExportRunResponse>;
/** Get export run status. */
getRun(runId: string, options?: ExportRunQueryOptions): Observable<ExportRunResponse>;
/** Stream export run events (SSE). */
streamRun(runId: string, options?: ExportRunQueryOptions): Observable<ExportRunEvent>;
/** Get distribution signed URLs. */
getDistribution(distributionId: string, options?: ExportRunQueryOptions): Observable<DistributionResponse>;
}
export const EXPORT_CENTER_API = new InjectionToken<ExportCenterApi>('EXPORT_CENTER_API');
/**
* HTTP Export Center Client.
* Implements WEB-EXPORT-35-001, WEB-EXPORT-36-001, WEB-EXPORT-37-001.
*/
@Injectable({ providedIn: 'root' })
export class ExportCenterHttpClient implements ExportCenterApi {
constructor(
private readonly http: HttpClient,
private readonly authSession: AuthSessionStore,
private readonly tenantService: TenantActivationService,
@Inject(EXPORT_CENTER_API_BASE_URL) private readonly baseUrl: string,
@Inject(EVENT_SOURCE_FACTORY) private readonly eventSourceFactory: EventSourceFactory = DEFAULT_EVENT_SOURCE_FACTORY
) {}
listProfiles(options: ExportProfilesQueryOptions = {}): Observable<ExportProfilesResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('export', 'read', ['export:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing export:read scope'));
}
const headers = this.buildHeaders(options);
let params = new HttpParams();
if (options.pageToken) {
params = params.set('pageToken', options.pageToken);
}
if (options.pageSize) {
params = params.set('pageSize', String(options.pageSize));
}
return this.http.get<ExportProfilesResponse>(`${this.baseUrl}/profiles`, { headers, params }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
startRun(request: ExportRunRequest, options: ExportRunQueryOptions = {}): Observable<ExportRunResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('export', 'write', ['export:write'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing export:write scope'));
}
let headers = this.buildHeaders(options);
if (options.idempotencyKey) {
headers = headers.set('Idempotency-Key', options.idempotencyKey);
}
return this.http.post<ExportRunResponse>(`${this.baseUrl}/runs`, request, { headers }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getRun(runId: string, options: ExportRunQueryOptions = {}): Observable<ExportRunResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('export', 'read', ['export:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing export:read scope'));
}
const headers = this.buildHeaders(options);
return this.http.get<ExportRunResponse>(
`${this.baseUrl}/runs/${encodeURIComponent(runId)}`,
{ headers }
).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
streamRun(runId: string, options: ExportRunQueryOptions = {}): Observable<ExportRunEvent> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
const url = `${this.baseUrl}/runs/${encodeURIComponent(runId)}/events?tenant=${encodeURIComponent(tenant)}&traceId=${encodeURIComponent(traceId)}`;
return new Observable<ExportRunEvent>((observer) => {
const source = this.eventSourceFactory(url);
const handleEvent = (eventType: string) => (event: MessageEvent) => {
try {
const data = JSON.parse(event.data);
observer.next({
event: eventType as ExportRunEvent['event'],
runId,
...data,
traceId,
});
} catch {
// Skip invalid JSON
}
};
source.addEventListener('started', handleEvent('started'));
source.addEventListener('progress', handleEvent('progress'));
source.addEventListener('artifact_ready', handleEvent('artifact_ready'));
source.addEventListener('completed', handleEvent('completed'));
source.addEventListener('failed', handleEvent('failed'));
source.onmessage = (event) => {
try {
const parsed = JSON.parse(event.data) as ExportRunEvent;
observer.next({ ...parsed, runId, traceId });
} catch {
// Ignore parse errors
}
};
source.onerror = () => {
observer.error(new Error(`[${traceId}] Export run stream error`));
source.close();
};
return () => source.close();
});
}
getDistribution(distributionId: string, options: ExportRunQueryOptions = {}): Observable<DistributionResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('export', 'read', ['export:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing export:read scope'));
}
const headers = this.buildHeaders(options);
return this.http.get<DistributionResponse>(
`${this.baseUrl}/distributions/${encodeURIComponent(distributionId)}`,
{ headers }
).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
private buildHeaders(opts: { tenantId?: string; traceId?: string }): HttpHeaders {
const tenant = this.resolveTenant(opts.tenantId);
const trace = opts.traceId ?? generateTraceId();
return new HttpHeaders({
'X-StellaOps-Tenant': tenant,
'X-Stella-Trace-Id': trace,
'X-Stella-Request-Id': trace,
Accept: 'application/json',
});
}
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
if (!tenant) {
throw new Error('ExportCenterClient requires an active tenant identifier.');
}
return tenant;
}
private mapError(err: unknown, traceId: string): Error {
if (err instanceof Error) {
return new Error(`[${traceId}] Export Center error: ${err.message}`);
}
return new Error(`[${traceId}] Export Center error: Unknown error`);
}
}
/**
* Mock Export Center API for quickstart mode.
*/
@Injectable({ providedIn: 'root' })
export class MockExportCenterClient implements ExportCenterApi {
private readonly mockProfiles: ExportProfile[] = [
{
profileId: 'export-profile::tenant-default::daily-vex',
name: 'Daily VEX Export',
description: 'Daily export of VEX statements and advisories',
targets: ['vex', 'advisory'],
formats: ['json', 'ndjson'],
schedule: '0 2 * * *',
retentionDays: 30,
createdAt: '2025-10-01T00:00:00Z',
updatedAt: '2025-11-15T10:00:00Z',
},
{
profileId: 'export-profile::tenant-default::weekly-full',
name: 'Weekly Full Export',
description: 'Weekly comprehensive export of all security data',
targets: ['vex', 'advisory', 'policy', 'scan', 'sbom'],
formats: ['json', 'ndjson', 'csv'],
schedule: '0 3 * * 0',
retentionDays: 90,
createdAt: '2025-09-15T00:00:00Z',
},
];
private runCounter = 0;
listProfiles(options: ExportProfilesQueryOptions = {}): Observable<ExportProfilesResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
items: this.mockProfiles,
total: this.mockProfiles.length,
traceId,
}).pipe(delay(50));
}
startRun(request: ExportRunRequest, options: ExportRunQueryOptions = {}): Observable<ExportRunResponse> {
const traceId = options.traceId ?? generateTraceId();
this.runCounter++;
const runId = `export-run::tenant-default::${new Date().toISOString().split('T')[0]}::${String(this.runCounter).padStart(4, '0')}`;
return of({
runId,
status: 'queued' as ExportRunStatus,
profileId: request.profileId,
estimateSeconds: 420,
links: {
status: `/export-center/runs/${runId}`,
events: `/export-center/runs/${runId}/events`,
},
retryAfter: 5,
traceId,
}).pipe(delay(100));
}
getRun(runId: string, options: ExportRunQueryOptions = {}): Observable<ExportRunResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
runId,
status: 'running' as ExportRunStatus,
startedAt: new Date(Date.now() - 60000).toISOString(),
outputs: [
{
type: 'manifest',
format: 'json' as ExportFormat,
url: `https://exports.local/tenant-default/${runId}/manifest.json?sig=mock`,
sha256: 'sha256:c0ffee1234567890',
dsseUrl: `https://exports.local/tenant-default/${runId}/manifest.dsse?sig=mock`,
expiresAt: new Date(Date.now() + 6 * 60 * 60 * 1000).toISOString(),
},
],
progress: {
percent: 35,
itemsCompleted: 70,
itemsTotal: 200,
},
errors: [],
traceId,
}).pipe(delay(50));
}
streamRun(runId: string, options: ExportRunQueryOptions = {}): Observable<ExportRunEvent> {
const traceId = options.traceId ?? generateTraceId();
return new Observable<ExportRunEvent>((observer) => {
// Emit started
setTimeout(() => {
observer.next({
event: 'started',
runId,
status: 'running',
traceId,
});
}, 100);
// Emit progress updates
let percent = 0;
const progressInterval = setInterval(() => {
percent += 10;
if (percent <= 100) {
observer.next({
event: 'progress',
runId,
percent,
itemsCompleted: percent * 2,
itemsTotal: 200,
traceId,
});
}
if (percent >= 100) {
clearInterval(progressInterval);
// Emit completed
observer.next({
event: 'completed',
runId,
status: 'succeeded',
manifestUrl: `https://exports.local/tenant-default/${runId}/manifest.json?sig=mock`,
manifestDsseUrl: `https://exports.local/tenant-default/${runId}/manifest.dsse?sig=mock`,
traceId,
});
observer.complete();
}
}, 500);
return () => clearInterval(progressInterval);
});
}
getDistribution(distributionId: string, options: ExportRunQueryOptions = {}): Observable<DistributionResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
distributionId,
type: 'oci' as const,
ref: 'registry.local/exports/daily:latest',
url: `https://registry.local/v2/exports/daily/manifests/latest?sig=mock`,
sha256: 'sha256:dist1234567890',
dsseUrl: `https://registry.local/v2/exports/daily/manifests/latest.dsse?sig=mock`,
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
size: 1024 * 1024 * 50,
traceId,
etag: `"dist-${distributionId}-${Date.now()}"`,
}).pipe(delay(30));
}
}

View File

@@ -0,0 +1,186 @@
/**
* Export Center Models.
* Implements WEB-EXPORT-35-001, WEB-EXPORT-36-001, WEB-EXPORT-37-001.
*/
/** Export run status. */
export type ExportRunStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'expired';
/** Export format. */
export type ExportFormat = 'json' | 'ndjson' | 'csv' | 'pdf';
/** Export target type. */
export type ExportTargetType = 'vex' | 'advisory' | 'policy' | 'scan' | 'sbom' | 'attestation';
/** Export priority. */
export type ExportPriority = 'low' | 'normal' | 'high';
/** Distribution type. */
export type DistributionType = 'oci' | 'object-storage' | 's3' | 'gcs' | 'azure-blob';
/** Export profile. */
export interface ExportProfile {
readonly profileId: string;
readonly name: string;
readonly description?: string;
readonly targets: readonly ExportTargetType[];
readonly formats: readonly ExportFormat[];
readonly schedule?: string;
readonly retentionDays?: number;
readonly createdAt: string;
readonly updatedAt?: string;
}
/** Export profiles list response. */
export interface ExportProfilesResponse {
readonly items: readonly ExportProfile[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Distribution signing config. */
export interface DistributionSigning {
readonly enabled: boolean;
readonly keyRef?: string;
}
/** Distribution config. */
export interface DistributionConfig {
readonly type: DistributionType;
readonly ref?: string;
readonly signing?: DistributionSigning;
}
/** Encryption config. */
export interface EncryptionConfig {
readonly enabled: boolean;
readonly kmsKey?: string;
}
/** Export run request. */
export interface ExportRunRequest {
readonly profileId?: string;
readonly targets: readonly ExportTargetType[];
readonly formats: readonly ExportFormat[];
readonly distribution?: DistributionConfig;
readonly retentionDays?: number;
readonly encryption?: EncryptionConfig;
readonly priority?: ExportPriority;
}
/** Export run links. */
export interface ExportRunLinks {
readonly status: string;
readonly events?: string;
}
/** Export run output. */
export interface ExportRunOutput {
readonly type: string;
readonly format: ExportFormat | string;
readonly url: string;
readonly sha256?: string;
readonly dsseUrl?: string;
readonly expiresAt?: string;
readonly size?: number;
}
/** Export run progress. */
export interface ExportRunProgress {
readonly percent: number;
readonly itemsCompleted?: number;
readonly itemsTotal?: number;
}
/** Export run error. */
export interface ExportRunError {
readonly code: string;
readonly message: string;
readonly field?: string;
}
/** Export run response. */
export interface ExportRunResponse {
readonly runId: string;
readonly status: ExportRunStatus;
readonly profileId?: string;
readonly startedAt?: string;
readonly completedAt?: string;
readonly estimateSeconds?: number;
readonly links?: ExportRunLinks;
readonly outputs?: readonly ExportRunOutput[];
readonly progress?: ExportRunProgress;
readonly errors?: readonly ExportRunError[];
readonly retryAfter?: number;
readonly traceId?: string;
}
/** Export SSE event types. */
export type ExportEventType =
| 'started'
| 'progress'
| 'artifact_ready'
| 'completed'
| 'failed';
/** Export SSE event. */
export interface ExportRunEvent {
readonly event: ExportEventType;
readonly runId: string;
readonly status?: ExportRunStatus;
readonly percent?: number;
readonly itemsCompleted?: number;
readonly itemsTotal?: number;
readonly type?: string;
readonly id?: string;
readonly url?: string;
readonly sha256?: string;
readonly format?: string;
readonly manifestUrl?: string;
readonly manifestDsseUrl?: string;
readonly code?: string;
readonly message?: string;
readonly retryAfterSeconds?: number;
readonly traceId?: string;
}
/** Distribution response. */
export interface DistributionResponse {
readonly distributionId: string;
readonly type: DistributionType;
readonly ref?: string;
readonly url: string;
readonly sha256?: string;
readonly dsseUrl?: string;
readonly expiresAt: string;
readonly size?: number;
readonly traceId?: string;
readonly etag?: string;
}
/** Export profile query options. */
export interface ExportProfilesQueryOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly pageToken?: string;
readonly pageSize?: number;
readonly traceId?: string;
}
/** Export run query options. */
export interface ExportRunQueryOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly idempotencyKey?: string;
readonly traceId?: string;
}
/** Export error codes. */
export type ExportErrorCode =
| 'ERR_EXPORT_PROFILE_NOT_FOUND'
| 'ERR_EXPORT_REQUEST_INVALID'
| 'ERR_EXPORT_TOO_LARGE'
| 'ERR_EXPORT_RATE_LIMIT'
| 'ERR_EXPORT_DISTRIBUTION_FAILED'
| 'ERR_EXPORT_EXPIRED';

View File

@@ -0,0 +1,461 @@
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { map, catchError, delay } from 'rxjs/operators';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import {
ObsHealthResponse,
ObsSloResponse,
TraceResponse,
LogsResponse,
LogsQueryOptions,
EvidenceResponse,
AttestationsResponse,
IncidentModeResponse,
IncidentModeRequest,
SealStatusResponse,
ObsQueryOptions,
} from './gateway-observability.models';
import { generateTraceId } from './trace.util';
export const OBS_API_BASE_URL = new InjectionToken<string>('OBS_API_BASE_URL');
/**
* Gateway Observability API interface.
* Implements WEB-OBS-50-001 through WEB-OBS-56-001.
*/
export interface GatewayObservabilityApi {
/** Get health status. WEB-OBS-51-001. */
getHealth(options?: ObsQueryOptions): Observable<ObsHealthResponse>;
/** Get SLO metrics. WEB-OBS-51-001. */
getSlos(options?: ObsQueryOptions): Observable<ObsSloResponse>;
/** Get trace by ID. WEB-OBS-52-001. */
getTrace(traceId: string, options?: ObsQueryOptions): Observable<TraceResponse>;
/** Query logs. WEB-OBS-52-001. */
queryLogs(query: LogsQueryOptions): Observable<LogsResponse>;
/** List evidence. WEB-OBS-54-001. */
listEvidence(options?: ObsQueryOptions): Observable<EvidenceResponse>;
/** List attestations. WEB-OBS-54-001. */
listAttestations(options?: ObsQueryOptions): Observable<AttestationsResponse>;
/** Get incident mode status. WEB-OBS-55-001. */
getIncidentMode(options?: ObsQueryOptions): Observable<IncidentModeResponse>;
/** Update incident mode. WEB-OBS-55-001. */
updateIncidentMode(request: IncidentModeRequest, options?: ObsQueryOptions): Observable<IncidentModeResponse>;
/** Get seal status. WEB-OBS-56-001. */
getSealStatus(options?: ObsQueryOptions): Observable<SealStatusResponse>;
}
export const GATEWAY_OBS_API = new InjectionToken<GatewayObservabilityApi>('GATEWAY_OBS_API');
/**
* HTTP Gateway Observability Client.
* Implements WEB-OBS-50-001 through WEB-OBS-56-001.
*/
@Injectable({ providedIn: 'root' })
export class GatewayObservabilityHttpClient implements GatewayObservabilityApi {
constructor(
private readonly http: HttpClient,
private readonly authSession: AuthSessionStore,
private readonly tenantService: TenantActivationService,
@Inject(OBS_API_BASE_URL) private readonly baseUrl: string
) {}
getHealth(options: ObsQueryOptions = {}): Observable<ObsHealthResponse> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(traceId);
return this.http.get<ObsHealthResponse>(`${this.baseUrl}/obs/health`, { headers }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getSlos(options: ObsQueryOptions = {}): Observable<ObsSloResponse> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(traceId);
return this.http.get<ObsSloResponse>(`${this.baseUrl}/obs/slo`, { headers }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getTrace(traceIdParam: string, options: ObsQueryOptions = {}): Observable<TraceResponse> {
const reqTraceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('obs', 'read', ['timeline:read'], options.projectId, reqTraceId)) {
return throwError(() => new Error('Unauthorized: missing timeline:read scope'));
}
const headers = this.buildHeaders(reqTraceId);
return this.http.get<TraceResponse>(
`${this.baseUrl}/obs/trace/${encodeURIComponent(traceIdParam)}`,
{ headers }
).pipe(
catchError((err) => throwError(() => this.mapError(err, reqTraceId)))
);
}
queryLogs(query: LogsQueryOptions): Observable<LogsResponse> {
const traceId = query.traceId ?? generateTraceId();
if (!this.tenantService.authorize('obs', 'read', ['timeline:read'], query.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing timeline:read scope'));
}
const headers = this.buildHeaders(traceId);
let params = new HttpParams();
if (query.service) params = params.set('service', query.service);
if (query.level) params = params.set('level', query.level);
if (query.traceId) params = params.set('traceId', query.traceId);
if (query.startTime) params = params.set('startTime', query.startTime);
if (query.endTime) params = params.set('endTime', query.endTime);
if (query.limit) params = params.set('limit', String(query.limit));
if (query.pageToken) params = params.set('pageToken', query.pageToken);
return this.http.get<LogsResponse>(`${this.baseUrl}/obs/logs`, { headers, params }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
listEvidence(options: ObsQueryOptions = {}): Observable<EvidenceResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('obs', 'read', ['evidence:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing evidence:read scope'));
}
const headers = this.buildHeaders(traceId);
const params = this.buildPaginationParams(options);
return this.http.get<EvidenceResponse>(`${this.baseUrl}/evidence`, { headers, params }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
listAttestations(options: ObsQueryOptions = {}): Observable<AttestationsResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('obs', 'read', ['attest:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing attest:read scope'));
}
const headers = this.buildHeaders(traceId);
const params = this.buildPaginationParams(options);
return this.http.get<AttestationsResponse>(`${this.baseUrl}/attestations`, { headers, params }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getIncidentMode(options: ObsQueryOptions = {}): Observable<IncidentModeResponse> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(traceId);
return this.http.get<IncidentModeResponse>(`${this.baseUrl}/obs/incident-mode`, { headers }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
updateIncidentMode(request: IncidentModeRequest, options: ObsQueryOptions = {}): Observable<IncidentModeResponse> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(traceId);
return this.http.post<IncidentModeResponse>(`${this.baseUrl}/obs/incident-mode`, request, { headers }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getSealStatus(options: ObsQueryOptions = {}): Observable<SealStatusResponse> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(traceId);
return this.http.get<SealStatusResponse>(`${this.baseUrl}/obs/seal-status`, { headers }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
private buildHeaders(traceId: string): HttpHeaders {
const tenant = this.authSession.getActiveTenantId() || '';
return new HttpHeaders({
'X-StellaOps-Tenant': tenant,
'X-Stella-Trace-Id': traceId,
'X-Stella-Request-Id': traceId,
Accept: 'application/json',
});
}
private buildPaginationParams(options: ObsQueryOptions): HttpParams {
let params = new HttpParams();
if (options.pageToken) {
params = params.set('pageToken', options.pageToken);
}
if (options.pageSize) {
params = params.set('pageSize', String(options.pageSize));
}
return params;
}
private mapError(err: unknown, traceId: string): Error {
if (err instanceof Error) {
return new Error(`[${traceId}] Observability error: ${err.message}`);
}
return new Error(`[${traceId}] Observability error: Unknown error`);
}
}
/**
* Mock Gateway Observability Client for quickstart mode.
*/
@Injectable({ providedIn: 'root' })
export class MockGatewayObservabilityClient implements GatewayObservabilityApi {
getHealth(options: ObsQueryOptions = {}): Observable<ObsHealthResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
status: 'healthy' as const,
checks: [
{ name: 'database', status: 'healthy' as const, latencyMs: 5, checkedAt: new Date().toISOString() },
{ name: 'cache', status: 'healthy' as const, latencyMs: 2, checkedAt: new Date().toISOString() },
{ name: 'queue', status: 'healthy' as const, latencyMs: 8, checkedAt: new Date().toISOString() },
],
uptimeSeconds: 86400,
timestamp: new Date().toISOString(),
traceId,
}).pipe(delay(50));
}
getSlos(options: ObsQueryOptions = {}): Observable<ObsSloResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
slos: [
{
name: 'Availability',
target: 99.9,
current: 99.95,
status: 'met' as const,
burnRate: 0.5,
errorBudgetRemaining: 0.05,
windowHours: 720,
},
{
name: 'Latency P99',
target: 200,
current: 180,
status: 'met' as const,
burnRate: 0.9,
errorBudgetRemaining: 0.1,
windowHours: 720,
},
{
name: 'Error Rate',
target: 0.1,
current: 0.08,
status: 'met' as const,
burnRate: 0.8,
errorBudgetRemaining: 0.02,
windowHours: 720,
},
],
exemplars: [
{ traceId: 'trace-001', timestamp: new Date().toISOString(), value: 150, labels: { endpoint: '/api/v1/vulns' } },
],
calculatedAt: new Date().toISOString(),
traceId,
}).pipe(delay(100));
}
getTrace(traceIdParam: string, options: ObsQueryOptions = {}): Observable<TraceResponse> {
return of({
traceId: traceIdParam,
spans: [
{
spanId: 'span-001',
operationName: 'HTTP GET /api/v1/vulns',
serviceName: 'gateway',
startTime: new Date(Date.now() - 200).toISOString(),
endTime: new Date().toISOString(),
durationMs: 200,
status: 'ok' as const,
attributes: { 'http.method': 'GET', 'http.status_code': 200 },
},
{
spanId: 'span-002',
parentSpanId: 'span-001',
operationName: 'DB query',
serviceName: 'concelier',
startTime: new Date(Date.now() - 150).toISOString(),
endTime: new Date(Date.now() - 50).toISOString(),
durationMs: 100,
status: 'ok' as const,
},
],
services: ['gateway', 'concelier'],
duration: 200,
timestamp: new Date().toISOString(),
}).pipe(delay(80));
}
queryLogs(query: LogsQueryOptions): Observable<LogsResponse> {
const traceId = query.traceId ?? generateTraceId();
return of({
items: [
{
timestamp: new Date().toISOString(),
level: 'info' as const,
message: 'Request processed successfully',
service: 'gateway',
traceId: 'trace-001',
},
{
timestamp: new Date(Date.now() - 1000).toISOString(),
level: 'debug' as const,
message: 'Cache hit for advisory lookup',
service: 'concelier',
},
],
total: 2,
traceId,
}).pipe(delay(60));
}
listEvidence(options: ObsQueryOptions = {}): Observable<EvidenceResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
items: [
{
evidenceId: 'ev-001',
type: 'scan' as const,
subjectDigest: 'sha256:abc123',
subjectName: 'myapp:latest',
createdAt: new Date().toISOString(),
provenance: {
builderName: 'scanner-v1',
buildId: 'build-001',
timestamp: new Date().toISOString(),
},
},
{
evidenceId: 'ev-002',
type: 'attestation' as const,
subjectDigest: 'sha256:abc123',
subjectName: 'myapp:latest',
createdAt: new Date().toISOString(),
},
],
total: 2,
traceId,
}).pipe(delay(50));
}
listAttestations(options: ObsQueryOptions = {}): Observable<AttestationsResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
items: [
{
attestationId: 'att-001',
predicateType: 'https://slsa.dev/provenance/v1',
subjectDigest: 'sha256:abc123',
subjectName: 'myapp:latest',
issuer: 'stellaops-attestor',
issuedAt: new Date().toISOString(),
verified: true,
verificationSummary: {
result: 'passed' as const,
},
},
],
total: 1,
traceId,
}).pipe(delay(50));
}
getIncidentMode(options: ObsQueryOptions = {}): Observable<IncidentModeResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
config: {
status: 'inactive' as const,
},
auditTrail: [
{
action: 'deactivated' as const,
actor: 'admin@example.com',
timestamp: new Date(Date.now() - 86400000).toISOString(),
details: 'Incident resolved',
},
],
traceId,
}).pipe(delay(40));
}
updateIncidentMode(request: IncidentModeRequest, options: ObsQueryOptions = {}): Observable<IncidentModeResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
config: {
status: request.action === 'enable' ? 'active' as const : request.action === 'schedule' ? 'scheduled' as const : 'inactive' as const,
activatedAt: request.action === 'enable' ? new Date().toISOString() : undefined,
activatedBy: 'user@example.com',
samplingOverride: request.samplingOverride,
retentionBumpDays: request.retentionBumpDays,
reason: request.reason,
},
auditTrail: [
{
action: request.action === 'enable' ? 'activated' as const : request.action === 'schedule' ? 'scheduled' as const : 'deactivated' as const,
actor: 'user@example.com',
timestamp: new Date().toISOString(),
details: request.reason,
},
],
traceId,
}).pipe(delay(100));
}
getSealStatus(options: ObsQueryOptions = {}): Observable<SealStatusResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
status: 'unsealed' as const,
unsealedAt: new Date(Date.now() - 3600000).toISOString(),
driftMetrics: [
{
component: 'scanner-config',
expectedHash: 'sha256:expected123',
actualHash: 'sha256:expected123',
drifted: false,
lastChecked: new Date().toISOString(),
},
{
component: 'policy-bundle',
expectedHash: 'sha256:expected456',
actualHash: 'sha256:expected456',
drifted: false,
lastChecked: new Date().toISOString(),
},
],
widgetData: {
sealedComponents: 0,
driftedComponents: 0,
totalComponents: 2,
lastSealVerification: new Date().toISOString(),
},
traceId,
}).pipe(delay(50));
}
}

View File

@@ -0,0 +1,298 @@
/**
* Gateway Observability Models.
* Implements WEB-OBS-50-001 through WEB-OBS-56-001.
*/
/** Health status. */
export type ObsHealthStatus = 'healthy' | 'degraded' | 'unhealthy' | 'unknown';
/** SLO status. */
export type ObsSloStatus = 'met' | 'at_risk' | 'breached';
/**
* WEB-OBS-50-001: Telemetry core integration.
*/
/** Trace context. */
export interface TraceContext {
readonly traceId: string;
readonly spanId: string;
readonly parentSpanId?: string;
readonly sampled: boolean;
}
/** Telemetry metadata. */
export interface TelemetryMetadata {
readonly tenantId: string;
readonly projectId?: string;
readonly service: string;
readonly operation: string;
readonly durationMs: number;
readonly statusCode?: number;
readonly errorCode?: string;
readonly trace: TraceContext;
}
/**
* WEB-OBS-51-001: Health and SLO aggregations.
*/
/** Health check result. */
export interface HealthCheckResult {
readonly name: string;
readonly status: ObsHealthStatus;
readonly message?: string;
readonly latencyMs?: number;
readonly checkedAt: string;
}
/** Health response. */
export interface ObsHealthResponse {
readonly status: ObsHealthStatus;
readonly checks: readonly HealthCheckResult[];
readonly uptimeSeconds?: number;
readonly timestamp: string;
readonly traceId?: string;
}
/** SLO metric. */
export interface SloMetric {
readonly name: string;
readonly target: number;
readonly current: number;
readonly status: ObsSloStatus;
readonly burnRate?: number;
readonly errorBudgetRemaining?: number;
readonly windowHours: number;
}
/** SLO exemplar. */
export interface SloExemplar {
readonly traceId: string;
readonly timestamp: string;
readonly value: number;
readonly labels?: Record<string, string>;
}
/** SLO response. */
export interface ObsSloResponse {
readonly slos: readonly SloMetric[];
readonly exemplars?: readonly SloExemplar[];
readonly calculatedAt: string;
readonly traceId?: string;
}
/**
* WEB-OBS-52-001: Trace and log proxy.
*/
/** Trace span. */
export interface TraceSpan {
readonly spanId: string;
readonly parentSpanId?: string;
readonly operationName: string;
readonly serviceName: string;
readonly startTime: string;
readonly endTime?: string;
readonly durationMs?: number;
readonly status: 'ok' | 'error' | 'unset';
readonly attributes?: Record<string, unknown>;
readonly events?: readonly SpanEvent[];
}
/** Span event. */
export interface SpanEvent {
readonly name: string;
readonly timestamp: string;
readonly attributes?: Record<string, unknown>;
}
/** Trace response. */
export interface TraceResponse {
readonly traceId: string;
readonly spans: readonly TraceSpan[];
readonly services: readonly string[];
readonly duration?: number;
readonly timestamp: string;
}
/** Log entry. */
export interface LogEntry {
readonly timestamp: string;
readonly level: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal';
readonly message: string;
readonly service?: string;
readonly traceId?: string;
readonly spanId?: string;
readonly attributes?: Record<string, unknown>;
}
/** Logs query options. */
export interface LogsQueryOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly service?: string;
readonly level?: LogEntry['level'];
readonly traceId?: string;
readonly startTime?: string;
readonly endTime?: string;
readonly limit?: number;
readonly pageToken?: string;
}
/** Logs response. */
export interface LogsResponse {
readonly items: readonly LogEntry[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly signedUrl?: string;
readonly traceId?: string;
}
/**
* WEB-OBS-54-001: Evidence and attestations.
*/
/** Evidence type. */
export type EvidenceType = 'scan' | 'attestation' | 'signature' | 'policy' | 'vex';
/** Evidence item. */
export interface EvidenceItem {
readonly evidenceId: string;
readonly type: EvidenceType;
readonly subjectDigest: string;
readonly subjectName?: string;
readonly createdAt: string;
readonly expiresAt?: string;
readonly provenance?: {
readonly builderName?: string;
readonly buildId?: string;
readonly timestamp: string;
};
readonly metadata?: Record<string, unknown>;
}
/** Evidence response. */
export interface EvidenceResponse {
readonly items: readonly EvidenceItem[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Attestation. */
export interface Attestation {
readonly attestationId: string;
readonly predicateType: string;
readonly subjectDigest: string;
readonly subjectName?: string;
readonly issuer?: string;
readonly issuedAt: string;
readonly expiresAt?: string;
readonly verified: boolean;
readonly verificationSummary?: {
readonly result: 'passed' | 'failed' | 'skipped';
readonly errors?: readonly string[];
readonly warnings?: readonly string[];
};
readonly metadata?: Record<string, unknown>;
}
/** Attestations response. */
export interface AttestationsResponse {
readonly items: readonly Attestation[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/**
* WEB-OBS-55-001: Incident mode.
*/
/** Incident mode status. */
export type IncidentModeStatus = 'active' | 'inactive' | 'scheduled';
/** Incident mode config. */
export interface IncidentModeConfig {
readonly status: IncidentModeStatus;
readonly activatedAt?: string;
readonly activatedBy?: string;
readonly deactivatedAt?: string;
readonly scheduledAt?: string;
readonly scheduledDuration?: number;
readonly samplingOverride?: number;
readonly retentionBumpDays?: number;
readonly reason?: string;
}
/** Incident mode response. */
export interface IncidentModeResponse {
readonly config: IncidentModeConfig;
readonly auditTrail: readonly {
readonly action: 'activated' | 'deactivated' | 'scheduled' | 'modified';
readonly actor: string;
readonly timestamp: string;
readonly details?: string;
}[];
readonly traceId?: string;
}
/** Incident mode request. */
export interface IncidentModeRequest {
readonly action: 'enable' | 'disable' | 'schedule';
readonly scheduledAt?: string;
readonly scheduledDuration?: number;
readonly samplingOverride?: number;
readonly retentionBumpDays?: number;
readonly reason?: string;
}
/**
* WEB-OBS-56-001: Sealed/unsealed status.
*/
/** Seal status. */
export type SealStatus = 'sealed' | 'unsealed' | 'transitioning';
/** Seal drift. */
export interface SealDrift {
readonly component: string;
readonly expectedHash: string;
readonly actualHash?: string;
readonly drifted: boolean;
readonly lastChecked: string;
}
/** Seal status response. */
export interface SealStatusResponse {
readonly status: SealStatus;
readonly sealedAt?: string;
readonly unsealedAt?: string;
readonly driftMetrics: readonly SealDrift[];
readonly widgetData?: {
readonly sealedComponents: number;
readonly driftedComponents: number;
readonly totalComponents: number;
readonly lastSealVerification: string;
};
readonly traceId?: string;
}
/** Observability query options. */
export interface ObsQueryOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly pageToken?: string;
readonly pageSize?: number;
readonly traceId?: string;
}
/** Observability error codes. */
export type ObsErrorCode =
| 'ERR_OBS_TRACE_NOT_FOUND'
| 'ERR_OBS_LOGS_TIMEOUT'
| 'ERR_OBS_EVIDENCE_NOT_FOUND'
| 'ERR_OBS_ATTESTATION_INVALID'
| 'ERR_OBS_INCIDENT_MODE_CONFLICT'
| 'ERR_OBS_SEAL_OPERATION_FAILED';

View File

@@ -0,0 +1,258 @@
import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http';
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { map, catchError, delay } from 'rxjs/operators';
import { AuthSessionStore } from '../auth/auth-session.store';
import {
OpenApiSpecResponse,
GatewayInfo,
GatewayHealthCheck,
DeprecatedRoutesResponse,
IdempotencyResponse,
RateLimitInfo,
OpenApiQueryOptions,
} from './gateway-openapi.models';
import { generateTraceId } from './trace.util';
export const GATEWAY_API_BASE_URL = new InjectionToken<string>('GATEWAY_API_BASE_URL');
/**
* Gateway OpenAPI API interface.
* Implements WEB-OAS-61-001, WEB-OAS-61-002, WEB-OAS-62-001, WEB-OAS-63-001.
*/
export interface GatewayOpenApiApi {
/** Get OpenAPI spec. WEB-OAS-61-001. */
getOpenApiSpec(options?: OpenApiQueryOptions): Observable<OpenApiSpecResponse>;
/** Get gateway info. */
getGatewayInfo(options?: OpenApiQueryOptions): Observable<GatewayInfo>;
/** Get gateway health. */
getGatewayHealth(options?: OpenApiQueryOptions): Observable<GatewayHealthCheck>;
/** Get deprecated routes. WEB-OAS-63-001. */
getDeprecatedRoutes(options?: OpenApiQueryOptions): Observable<DeprecatedRoutesResponse>;
/** Check idempotency key. WEB-OAS-62-001. */
checkIdempotencyKey(key: string, options?: OpenApiQueryOptions): Observable<IdempotencyResponse>;
/** Get rate limit info. WEB-OAS-62-001. */
getRateLimitInfo(options?: OpenApiQueryOptions): Observable<RateLimitInfo>;
}
export const GATEWAY_OPENAPI_API = new InjectionToken<GatewayOpenApiApi>('GATEWAY_OPENAPI_API');
/**
* HTTP Gateway OpenAPI Client.
* Implements WEB-OAS-61-001, WEB-OAS-61-002, WEB-OAS-62-001, WEB-OAS-63-001.
*/
@Injectable({ providedIn: 'root' })
export class GatewayOpenApiHttpClient implements GatewayOpenApiApi {
constructor(
private readonly http: HttpClient,
private readonly authSession: AuthSessionStore,
@Inject(GATEWAY_API_BASE_URL) private readonly baseUrl: string
) {}
getOpenApiSpec(options: OpenApiQueryOptions = {}): Observable<OpenApiSpecResponse> {
const traceId = options.traceId ?? generateTraceId();
let headers = this.buildHeaders(traceId);
if (options.ifNoneMatch) {
headers = headers.set('If-None-Match', options.ifNoneMatch);
}
return this.http.get<OpenApiSpecResponse>(
`${this.baseUrl}/.well-known/openapi`,
{ headers, observe: 'response' }
).pipe(
map((response: HttpResponse<OpenApiSpecResponse>) => {
const body = response.body!;
const etag = response.headers.get('ETag') || body.etag;
return { ...body, etag, traceId };
}),
catchError((err) => {
if (err.status === 304) {
return throwError(() => new Error(`[${traceId}] Not Modified`));
}
return throwError(() => this.mapError(err, traceId));
})
);
}
getGatewayInfo(options: OpenApiQueryOptions = {}): Observable<GatewayInfo> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(traceId);
return this.http.get<GatewayInfo>(`${this.baseUrl}/info`, { headers }).pipe(
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getGatewayHealth(options: OpenApiQueryOptions = {}): Observable<GatewayHealthCheck> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(traceId);
return this.http.get<GatewayHealthCheck>(`${this.baseUrl}/health`, { headers }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getDeprecatedRoutes(options: OpenApiQueryOptions = {}): Observable<DeprecatedRoutesResponse> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(traceId);
return this.http.get<DeprecatedRoutesResponse>(`${this.baseUrl}/deprecated-routes`, { headers }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
checkIdempotencyKey(key: string, options: OpenApiQueryOptions = {}): Observable<IdempotencyResponse> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(traceId);
return this.http.get<IdempotencyResponse>(
`${this.baseUrl}/idempotency/${encodeURIComponent(key)}`,
{ headers }
).pipe(
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getRateLimitInfo(options: OpenApiQueryOptions = {}): Observable<RateLimitInfo> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(traceId);
return this.http.get<RateLimitInfo>(`${this.baseUrl}/rate-limit`, { headers }).pipe(
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
private buildHeaders(traceId: string): HttpHeaders {
const tenant = this.authSession.getActiveTenantId() || '';
return new HttpHeaders({
'X-StellaOps-Tenant': tenant,
'X-Stella-Trace-Id': traceId,
'X-Stella-Request-Id': traceId,
Accept: 'application/json',
});
}
private mapError(err: unknown, traceId: string): Error {
if (err instanceof Error) {
return new Error(`[${traceId}] Gateway OpenAPI error: ${err.message}`);
}
return new Error(`[${traceId}] Gateway OpenAPI error: Unknown error`);
}
}
/**
* Mock Gateway OpenAPI Client for quickstart mode.
*/
@Injectable({ providedIn: 'root' })
export class MockGatewayOpenApiClient implements GatewayOpenApiApi {
private readonly mockSpec: OpenApiSpecResponse = {
openapi: '3.1.0',
info: {
title: 'StellaOps Gateway API',
version: '1.0.0',
description: 'Gateway API for StellaOps platform',
},
paths: {
'/health': { get: { summary: 'Health check' } },
'/info': { get: { summary: 'Gateway info' } },
'/.well-known/openapi': { get: { summary: 'OpenAPI spec' } },
},
etag: '"spec-v1.0.0-20251211"',
versionInfo: {
specVersion: '1.0.0',
gatewayVersion: '1.0.0',
buildTimestamp: '2025-12-11T00:00:00Z',
gitCommit: 'abc123',
},
};
private readonly mockGatewayInfo: GatewayInfo = {
name: 'StellaOps Gateway',
version: '1.0.0',
environment: 'development',
region: 'local',
features: [
'rate-limiting',
'idempotency',
'cursor-pagination',
'deprecation-headers',
'etag-caching',
],
uptime: 86400,
};
private readonly mockDeprecatedRoutes: DeprecatedRoutesResponse = {
items: [
{
path: '/api/v1/vulnerabilities',
method: 'GET',
deprecation: {
deprecated: true,
sunsetAt: '2026-06-01T00:00:00Z',
replacedBy: '/api/v2/findings',
migrationGuide: 'https://docs.stellaops.local/migration/v2-findings',
},
},
],
total: 1,
};
getOpenApiSpec(options: OpenApiQueryOptions = {}): Observable<OpenApiSpecResponse> {
const traceId = options.traceId ?? generateTraceId();
// Simulate ETag caching
if (options.ifNoneMatch === this.mockSpec.etag) {
return throwError(() => new Error(`[${traceId}] Not Modified`)).pipe(delay(10));
}
return of({ ...this.mockSpec, traceId }).pipe(delay(50));
}
getGatewayInfo(_options: OpenApiQueryOptions = {}): Observable<GatewayInfo> {
return of({ ...this.mockGatewayInfo }).pipe(delay(30));
}
getGatewayHealth(options: OpenApiQueryOptions = {}): Observable<GatewayHealthCheck> {
const traceId = options.traceId ?? generateTraceId();
return of({
status: 'healthy' as const,
checks: [
{ name: 'database', status: 'healthy' as const, latencyMs: 5 },
{ name: 'cache', status: 'healthy' as const, latencyMs: 2 },
{ name: 'upstream', status: 'healthy' as const, latencyMs: 15 },
],
timestamp: new Date().toISOString(),
traceId,
}).pipe(delay(50));
}
getDeprecatedRoutes(options: OpenApiQueryOptions = {}): Observable<DeprecatedRoutesResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({ ...this.mockDeprecatedRoutes, traceId }).pipe(delay(30));
}
checkIdempotencyKey(key: string, _options: OpenApiQueryOptions = {}): Observable<IdempotencyResponse> {
return of({
idempotencyKey: key,
status: 'accepted' as const,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
}).pipe(delay(30));
}
getRateLimitInfo(_options: OpenApiQueryOptions = {}): Observable<RateLimitInfo> {
return of({
limit: 1000,
remaining: 950,
reset: Math.floor(Date.now() / 1000) + 3600,
}).pipe(delay(20));
}
}

View File

@@ -0,0 +1,138 @@
/**
* Gateway OpenAPI Models.
* Implements WEB-OAS-61-001, WEB-OAS-61-002, WEB-OAS-62-001, WEB-OAS-63-001.
*/
/** OpenAPI spec version info. */
export interface OpenApiVersionInfo {
readonly specVersion: string;
readonly gatewayVersion: string;
readonly buildTimestamp: string;
readonly gitCommit?: string;
}
/** OpenAPI spec response. */
export interface OpenApiSpecResponse {
readonly openapi: string;
readonly info: {
readonly title: string;
readonly version: string;
readonly description?: string;
};
readonly paths: Record<string, unknown>;
readonly components?: Record<string, unknown>;
readonly etag: string;
readonly versionInfo: OpenApiVersionInfo;
readonly traceId?: string;
}
/** Standard error envelope. */
export interface GatewayErrorEnvelope {
readonly error: {
readonly code: string;
readonly message: string;
readonly details?: readonly GatewayErrorDetail[];
readonly traceId: string;
readonly timestamp: string;
};
}
/** Error detail. */
export interface GatewayErrorDetail {
readonly field?: string;
readonly reason: string;
readonly value?: string;
}
/** Rate limit info. */
export interface RateLimitInfo {
readonly limit: number;
readonly remaining: number;
readonly reset: number;
readonly retryAfter?: number;
}
/** Pagination cursor. */
export interface PaginationCursor {
readonly pageToken?: string | null;
readonly pageSize?: number;
readonly hasMore?: boolean;
readonly total?: number;
}
/** Idempotency status. */
export type IdempotencyStatus = 'accepted' | 'duplicate' | 'expired';
/** Idempotency response. */
export interface IdempotencyResponse {
readonly idempotencyKey: string;
readonly status: IdempotencyStatus;
readonly originalRequestId?: string;
readonly expiresAt: string;
}
/** Deprecation info. */
export interface DeprecationInfo {
readonly deprecated: boolean;
readonly sunsetAt?: string;
readonly replacedBy?: string;
readonly migrationGuide?: string;
}
/** Deprecated route. */
export interface DeprecatedRoute {
readonly path: string;
readonly method: string;
readonly deprecation: DeprecationInfo;
}
/** Deprecated routes response. */
export interface DeprecatedRoutesResponse {
readonly items: readonly DeprecatedRoute[];
readonly total: number;
readonly traceId?: string;
}
/** Gateway info. */
export interface GatewayInfo {
readonly name: string;
readonly version: string;
readonly environment: string;
readonly region?: string;
readonly features: readonly string[];
readonly uptime?: number;
}
/** Gateway health status. */
export type GatewayHealthStatus = 'healthy' | 'degraded' | 'unhealthy';
/** Gateway health check. */
export interface GatewayHealthCheck {
readonly status: GatewayHealthStatus;
readonly checks: readonly {
readonly name: string;
readonly status: GatewayHealthStatus;
readonly message?: string;
readonly latencyMs?: number;
}[];
readonly timestamp: string;
readonly traceId?: string;
}
/** OpenAPI query options. */
export interface OpenApiQueryOptions {
readonly tenantId?: string;
readonly traceId?: string;
readonly ifNoneMatch?: string;
}
/** Gateway error codes. */
export type GatewayErrorCode =
| 'ERR_GATEWAY_UNAUTHORIZED'
| 'ERR_GATEWAY_FORBIDDEN'
| 'ERR_GATEWAY_NOT_FOUND'
| 'ERR_GATEWAY_RATE_LIMIT'
| 'ERR_GATEWAY_VALIDATION'
| 'ERR_GATEWAY_IDEMPOTENCY'
| 'ERR_GATEWAY_UPSTREAM'
| 'ERR_GATEWAY_TIMEOUT';

View File

@@ -0,0 +1,448 @@
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { map, catchError, delay } from 'rxjs/operators';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import {
GraphMetadata,
GraphListResponse,
GraphTileResponse,
GraphQueryOptions,
TileQueryOptions,
GraphSearchOptions,
GraphSearchResponse,
PathFindOptions,
PathFindResponse,
GraphExportOptions,
GraphExportResponse,
AssetSnapshot,
AdjacencyResponse,
GraphBuildStatus,
GraphNodeKind,
GraphSeverity,
GraphReachability,
GraphNode,
GraphEdge,
} from './graph-platform.models';
import { generateTraceId } from './trace.util';
export const GRAPH_API_BASE_URL = new InjectionToken<string>('GRAPH_API_BASE_URL');
/**
* Graph Platform API interface.
* Implements WEB-GRAPH-SPEC-21-000 through WEB-GRAPH-24-004.
*/
export interface GraphPlatformApi {
/** List available graphs. */
listGraphs(options?: GraphQueryOptions): Observable<GraphListResponse>;
/** Get graph metadata. */
getGraph(graphId: string, options?: GraphQueryOptions): Observable<GraphMetadata>;
/** Get graph tile with nodes, edges, and overlays. */
getTile(graphId: string, options?: TileQueryOptions): Observable<GraphTileResponse>;
/** Search graph nodes. */
search(options: GraphSearchOptions): Observable<GraphSearchResponse>;
/** Find paths between nodes. */
findPath(options: PathFindOptions): Observable<PathFindResponse>;
/** Export graph in various formats. */
exportGraph(graphId: string, options: GraphExportOptions): Observable<GraphExportResponse>;
/** Get asset snapshot. */
getAssetSnapshot(assetId: string, options?: GraphQueryOptions): Observable<AssetSnapshot>;
/** Get node adjacency. */
getAdjacency(nodeId: string, options?: GraphQueryOptions): Observable<AdjacencyResponse>;
}
export const GRAPH_PLATFORM_API = new InjectionToken<GraphPlatformApi>('GRAPH_PLATFORM_API');
/**
* HTTP Graph Platform Client.
*/
@Injectable({ providedIn: 'root' })
export class GraphPlatformHttpClient implements GraphPlatformApi {
constructor(
private readonly http: HttpClient,
private readonly authSession: AuthSessionStore,
private readonly tenantService: TenantActivationService,
@Inject(GRAPH_API_BASE_URL) private readonly baseUrl: string
) {}
listGraphs(options: GraphQueryOptions = {}): Observable<GraphListResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('graph', 'read', ['graph:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing graph:read scope'));
}
const headers = this.buildHeaders(options);
let params = new HttpParams();
if (options.pageToken) params = params.set('pageToken', options.pageToken);
if (options.pageSize) params = params.set('pageSize', String(options.pageSize));
if (options.status) params = params.set('status', options.status);
return this.http.get<GraphListResponse>(`${this.baseUrl}/graphs`, { headers, params }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getGraph(graphId: string, options: GraphQueryOptions = {}): Observable<GraphMetadata> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('graph', 'read', ['graph:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing graph:read scope'));
}
const headers = this.buildHeaders(options);
return this.http.get<GraphMetadata>(
`${this.baseUrl}/graphs/${encodeURIComponent(graphId)}`,
{ headers }
).pipe(
map((response) => ({ ...response })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getTile(graphId: string, options: TileQueryOptions = {}): Observable<GraphTileResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('graph', 'read', ['graph:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing graph:read scope'));
}
const headers = this.buildHeaders(options);
let params = new HttpParams();
if (options.bbox) {
params = params.set('bbox', `${options.bbox.minX},${options.bbox.minY},${options.bbox.maxX},${options.bbox.maxY}`);
}
if (options.zoom !== undefined) params = params.set('zoom', String(options.zoom));
if (options.path) params = params.set('path', options.path);
if (options.includeOverlays !== undefined) params = params.set('includeOverlays', String(options.includeOverlays));
return this.http.get<GraphTileResponse>(
`${this.baseUrl}/graphs/${encodeURIComponent(graphId)}/tiles`,
{ headers, params }
).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
search(options: GraphSearchOptions): Observable<GraphSearchResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('graph', 'read', ['graph:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing graph:read scope'));
}
const headers = this.buildHeaders(options);
let params = new HttpParams().set('q', options.query);
if (options.pageToken) params = params.set('pageToken', options.pageToken);
if (options.pageSize) params = params.set('pageSize', String(options.pageSize));
if (options.kinds?.length) params = params.set('kinds', options.kinds.join(','));
if (options.severity?.length) params = params.set('severity', options.severity.join(','));
if (options.reachability?.length) params = params.set('reachability', options.reachability.join(','));
if (options.graphId) params = params.set('graphId', options.graphId);
return this.http.get<GraphSearchResponse>(`${this.baseUrl}/search`, { headers, params }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
findPath(options: PathFindOptions): Observable<PathFindResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('graph', 'read', ['graph:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing graph:read scope'));
}
const headers = this.buildHeaders(options);
let params = new HttpParams()
.set('source', options.sourceId)
.set('target', options.targetId);
if (options.maxDepth) params = params.set('maxDepth', String(options.maxDepth));
if (options.includeEvidence !== undefined) params = params.set('includeEvidence', String(options.includeEvidence));
if (options.graphId) params = params.set('graphId', options.graphId);
return this.http.get<PathFindResponse>(`${this.baseUrl}/paths`, { headers, params }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
exportGraph(graphId: string, options: GraphExportOptions): Observable<GraphExportResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('graph', 'read', ['graph:read', 'graph:export'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing graph:export scope'));
}
const headers = this.buildHeaders(options);
let params = new HttpParams().set('format', options.format);
if (options.bbox) {
params = params.set('bbox', `${options.bbox.minX},${options.bbox.minY},${options.bbox.maxX},${options.bbox.maxY}`);
}
if (options.includeOverlays !== undefined) params = params.set('includeOverlays', String(options.includeOverlays));
return this.http.get<GraphExportResponse>(
`${this.baseUrl}/graphs/${encodeURIComponent(graphId)}/export`,
{ headers, params }
).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getAssetSnapshot(assetId: string, options: GraphQueryOptions = {}): Observable<AssetSnapshot> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('graph', 'read', ['graph:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing graph:read scope'));
}
const headers = this.buildHeaders(options);
return this.http.get<AssetSnapshot>(
`${this.baseUrl}/assets/${encodeURIComponent(assetId)}/snapshot`,
{ headers }
).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getAdjacency(nodeId: string, options: GraphQueryOptions = {}): Observable<AdjacencyResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('graph', 'read', ['graph:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing graph:read scope'));
}
const headers = this.buildHeaders(options);
let params = new HttpParams();
if (options.graphId) params = params.set('graphId', options.graphId);
return this.http.get<AdjacencyResponse>(
`${this.baseUrl}/nodes/${encodeURIComponent(nodeId)}/adjacency`,
{ headers, params }
).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
private buildHeaders(opts: { tenantId?: string; traceId?: string; ifNoneMatch?: string }): HttpHeaders {
const tenant = this.resolveTenant(opts.tenantId);
const trace = opts.traceId ?? generateTraceId();
let headers = new HttpHeaders({
'X-StellaOps-Tenant': tenant,
'X-Stella-Trace-Id': trace,
'X-Stella-Request-Id': trace,
Accept: 'application/json',
});
if (opts.ifNoneMatch) {
headers = headers.set('If-None-Match', opts.ifNoneMatch);
}
return headers;
}
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
if (!tenant) {
throw new Error('GraphPlatformClient requires an active tenant identifier.');
}
return tenant;
}
private mapError(err: unknown, traceId: string): Error {
if (err instanceof Error) {
return new Error(`[${traceId}] Graph Platform error: ${err.message}`);
}
return new Error(`[${traceId}] Graph Platform error: Unknown error`);
}
}
/**
* Mock Graph Platform API for quickstart mode.
*/
@Injectable({ providedIn: 'root' })
export class MockGraphPlatformClient implements GraphPlatformApi {
private readonly mockGraphs: GraphMetadata[] = [
{
graphId: 'graph::tenant-default::main',
tenantId: 'tenant-default',
name: 'Main Dependency Graph',
description: 'Primary dependency graph for all projects',
status: 'ready',
nodeCount: 1250,
edgeCount: 3400,
snapshotAt: '2025-12-10T06:00:00Z',
createdAt: '2025-10-01T00:00:00Z',
updatedAt: '2025-12-10T06:00:00Z',
etag: '"graph-main-v1"',
},
];
private readonly mockNodes: GraphNode[] = [
{ id: 'asset::registry.local/ops/auth', kind: 'asset', label: 'auth-service', severity: 'high', reachability: 'reachable' },
{ id: 'component::pkg:npm/jsonwebtoken@9.0.2', kind: 'component', label: 'jsonwebtoken@9.0.2', severity: 'high', reachability: 'reachable' },
{ id: 'vuln::CVE-2024-12345', kind: 'vuln', label: 'CVE-2024-12345', severity: 'high' },
{ id: 'asset::registry.local/ops/transform', kind: 'asset', label: 'transform-service', severity: 'critical', reachability: 'reachable' },
{ id: 'component::pkg:npm/lodash@4.17.20', kind: 'component', label: 'lodash@4.17.20', severity: 'critical', reachability: 'reachable' },
{ id: 'vuln::CVE-2024-67890', kind: 'vuln', label: 'CVE-2024-67890', severity: 'critical' },
];
private readonly mockEdges: GraphEdge[] = [
{ id: 'edge-1', source: 'asset::registry.local/ops/auth', target: 'component::pkg:npm/jsonwebtoken@9.0.2', type: 'contains' },
{ id: 'edge-2', source: 'component::pkg:npm/jsonwebtoken@9.0.2', target: 'vuln::CVE-2024-12345', type: 'affects' },
{ id: 'edge-3', source: 'asset::registry.local/ops/transform', target: 'component::pkg:npm/lodash@4.17.20', type: 'contains' },
{ id: 'edge-4', source: 'component::pkg:npm/lodash@4.17.20', target: 'vuln::CVE-2024-67890', type: 'affects' },
];
listGraphs(options: GraphQueryOptions = {}): Observable<GraphListResponse> {
const traceId = options.traceId ?? generateTraceId();
let filtered = [...this.mockGraphs];
if (options.status) {
filtered = filtered.filter((g) => g.status === options.status);
}
return of({ items: filtered, total: filtered.length, traceId }).pipe(delay(50));
}
getGraph(graphId: string, options: GraphQueryOptions = {}): Observable<GraphMetadata> {
const graph = this.mockGraphs.find((g) => g.graphId === graphId);
if (!graph) {
return throwError(() => new Error(`Graph ${graphId} not found`));
}
return of(graph).pipe(delay(30));
}
getTile(graphId: string, options: TileQueryOptions = {}): Observable<GraphTileResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
version: '2025-12-06',
tenantId: 'tenant-default',
tile: {
id: `graph-tile::${graphId}::z${options.zoom ?? 8}`,
zoom: options.zoom ?? 8,
etag: '"tile-v1"',
},
nodes: this.mockNodes,
edges: this.mockEdges,
overlays: options.includeOverlays ? {
policy: [
{ nodeId: 'component::pkg:npm/jsonwebtoken@9.0.2', badge: 'fail', policyId: 'policy://tenant-default/runtime', verdictAt: '2025-12-10T06:00:00Z' },
{ nodeId: 'component::pkg:npm/lodash@4.17.20', badge: 'fail', policyId: 'policy://tenant-default/runtime', verdictAt: '2025-12-10T06:00:00Z' },
],
vex: [
{ nodeId: 'vuln::CVE-2024-12345', state: 'under_investigation', statementId: 'vex:tenant-default:jwt-auth:5d1a', lastUpdated: '2025-12-10T06:00:00Z' },
{ nodeId: 'vuln::CVE-2024-67890', state: 'affected', statementId: 'vex:tenant-default:data-transform:9bf4', lastUpdated: '2025-12-10T06:00:00Z' },
],
aoc: [],
} : undefined,
telemetry: { generationMs: 45, cache: 'miss', samples: this.mockNodes.length },
traceId,
etag: '"tile-response-v1"',
}).pipe(delay(75));
}
search(options: GraphSearchOptions): Observable<GraphSearchResponse> {
const traceId = options.traceId ?? generateTraceId();
const query = options.query.toLowerCase();
const results = this.mockNodes
.filter((n) => n.label.toLowerCase().includes(query) || n.id.toLowerCase().includes(query))
.filter((n) => !options.kinds?.length || options.kinds.includes(n.kind))
.filter((n) => !options.severity?.length || (n.severity && options.severity.includes(n.severity)))
.filter((n) => !options.reachability?.length || (n.reachability && options.reachability.includes(n.reachability)))
.map((n, i) => ({
nodeId: n.id,
kind: n.kind,
label: n.label,
score: 1 - i * 0.1,
severity: n.severity,
reachability: n.reachability,
highlights: [n.label],
}));
return of({ items: results, total: results.length, traceId }).pipe(delay(50));
}
findPath(options: PathFindOptions): Observable<PathFindResponse> {
const traceId = options.traceId ?? generateTraceId();
// Simplified path finding for mock
const sourceNode = this.mockNodes.find((n) => n.id === options.sourceId);
const targetNode = this.mockNodes.find((n) => n.id === options.targetId);
if (!sourceNode || !targetNode) {
return of({ paths: [], totalPaths: 0, traceId }).pipe(delay(30));
}
// Check if there's a direct edge
const directEdge = this.mockEdges.find((e) => e.source === options.sourceId && e.target === options.targetId);
if (directEdge) {
return of({
paths: [[
{ node: sourceNode, depth: 0 },
{ node: targetNode, edge: directEdge, depth: 1 },
]],
shortestLength: 1,
totalPaths: 1,
traceId,
}).pipe(delay(50));
}
return of({ paths: [], totalPaths: 0, traceId }).pipe(delay(30));
}
exportGraph(graphId: string, options: GraphExportOptions): Observable<GraphExportResponse> {
const traceId = options.traceId ?? generateTraceId();
const exportId = `graph-export::${graphId}::${Date.now()}`;
return of({
exportId,
format: options.format,
url: `https://exports.local/graphs/${graphId}/export.${options.format}?sig=mock`,
sha256: 'sha256:graphexport1234',
size: 1024 * 100,
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
traceId,
}).pipe(delay(100));
}
getAssetSnapshot(assetId: string, options: GraphQueryOptions = {}): Observable<AssetSnapshot> {
const traceId = options.traceId ?? generateTraceId();
return of({
assetId,
name: assetId.split('::').pop() ?? assetId,
kind: 'container',
components: ['pkg:npm/jsonwebtoken@9.0.2', 'pkg:npm/express@4.18.1'],
vulnerabilities: ['CVE-2024-12345'],
snapshotAt: new Date().toISOString(),
traceId,
}).pipe(delay(30));
}
getAdjacency(nodeId: string, options: GraphQueryOptions = {}): Observable<AdjacencyResponse> {
const traceId = options.traceId ?? generateTraceId();
const incoming = this.mockEdges.filter((e) => e.target === nodeId).map((e) => ({ nodeId: e.source, edgeType: e.type }));
const outgoing = this.mockEdges.filter((e) => e.source === nodeId).map((e) => ({ nodeId: e.target, edgeType: e.type }));
return of({ nodeId, incoming, outgoing, traceId }).pipe(delay(30));
}
}

View File

@@ -0,0 +1,256 @@
/**
* Graph Platform Models.
* Implements WEB-GRAPH-SPEC-21-000 through WEB-GRAPH-24-004.
*/
/** Graph build status. */
export type GraphBuildStatus = 'pending' | 'building' | 'ready' | 'failed' | 'expired';
/** Node kind. */
export type GraphNodeKind = 'asset' | 'component' | 'vuln' | 'advisory' | 'policy' | 'evidence';
/** Severity level. */
export type GraphSeverity = 'critical' | 'high' | 'medium' | 'low' | 'info' | 'unknown';
/** Reachability status. */
export type GraphReachability = 'reachable' | 'unreachable' | 'unknown';
/** Edge type. */
export type GraphEdgeType = 'depends_on' | 'contains' | 'evidence' | 'affects' | 'mitigates';
/** Policy badge. */
export type GraphPolicyBadge = 'pass' | 'warn' | 'fail' | 'waived';
/** VEX state. */
export type GraphVexState = 'not_affected' | 'fixed' | 'under_investigation' | 'affected';
/** AOC status. */
export type GraphAocStatus = 'pass' | 'fail' | 'warn' | 'pending';
/** Graph metadata. */
export interface GraphMetadata {
readonly graphId: string;
readonly tenantId: string;
readonly name: string;
readonly description?: string;
readonly status: GraphBuildStatus;
readonly nodeCount?: number;
readonly edgeCount?: number;
readonly snapshotAt?: string;
readonly createdAt: string;
readonly updatedAt?: string;
readonly etag?: string;
}
/** Graph list response. */
export interface GraphListResponse {
readonly items: readonly GraphMetadata[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Graph node. */
export interface GraphNode {
readonly id: string;
readonly kind: GraphNodeKind;
readonly label: string;
readonly severity?: GraphSeverity;
readonly reachability?: GraphReachability;
readonly attributes?: Record<string, unknown>;
}
/** Graph edge. */
export interface GraphEdge {
readonly id: string;
readonly source: string;
readonly target: string;
readonly type: GraphEdgeType;
readonly weight?: number;
readonly attributes?: Record<string, unknown>;
}
/** Policy overlay. */
export interface PolicyOverlay {
readonly nodeId: string;
readonly badge: GraphPolicyBadge;
readonly policyId: string;
readonly verdictAt?: string;
}
/** VEX overlay. */
export interface VexOverlay {
readonly nodeId: string;
readonly state: GraphVexState;
readonly statementId: string;
readonly lastUpdated?: string;
}
/** AOC overlay. */
export interface AocOverlay {
readonly nodeId: string;
readonly status: GraphAocStatus;
readonly lastVerified?: string;
}
/** Graph overlays. */
export interface GraphOverlays {
readonly policy?: readonly PolicyOverlay[];
readonly vex?: readonly VexOverlay[];
readonly aoc?: readonly AocOverlay[];
}
/** Tile bounding box. */
export interface TileBbox {
readonly minX: number;
readonly minY: number;
readonly maxX: number;
readonly maxY: number;
}
/** Tile metadata. */
export interface TileMetadata {
readonly id: string;
readonly bbox?: TileBbox;
readonly zoom?: number;
readonly etag?: string;
}
/** Graph tile telemetry. */
export interface TileTelemetry {
readonly generationMs?: number;
readonly cache?: 'hit' | 'miss';
readonly samples?: number;
}
/** Graph tile response. */
export interface GraphTileResponse {
readonly version: string;
readonly tenantId: string;
readonly tile: TileMetadata;
readonly nodes: readonly GraphNode[];
readonly edges: readonly GraphEdge[];
readonly overlays?: GraphOverlays;
readonly telemetry?: TileTelemetry;
readonly traceId?: string;
readonly etag?: string;
}
/** Graph query options. */
export interface GraphQueryOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly graphId?: string;
readonly pageToken?: string;
readonly pageSize?: number;
readonly status?: GraphBuildStatus;
readonly traceId?: string;
readonly ifNoneMatch?: string;
}
/** Tile query options. */
export interface TileQueryOptions extends GraphQueryOptions {
readonly bbox?: TileBbox;
readonly zoom?: number;
readonly path?: string;
readonly includeOverlays?: boolean;
}
/** Search query options. */
export interface GraphSearchOptions extends GraphQueryOptions {
readonly query: string;
readonly kinds?: readonly GraphNodeKind[];
readonly severity?: readonly GraphSeverity[];
readonly reachability?: readonly GraphReachability[];
}
/** Search result. */
export interface GraphSearchResult {
readonly nodeId: string;
readonly kind: GraphNodeKind;
readonly label: string;
readonly score: number;
readonly severity?: GraphSeverity;
readonly reachability?: GraphReachability;
readonly highlights?: readonly string[];
}
/** Search response. */
export interface GraphSearchResponse {
readonly items: readonly GraphSearchResult[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Path finding options. */
export interface PathFindOptions extends GraphQueryOptions {
readonly sourceId: string;
readonly targetId: string;
readonly maxDepth?: number;
readonly includeEvidence?: boolean;
}
/** Path step. */
export interface PathStep {
readonly node: GraphNode;
readonly edge?: GraphEdge;
readonly depth: number;
}
/** Path finding response. */
export interface PathFindResponse {
readonly paths: readonly (readonly PathStep[])[];
readonly shortestLength?: number;
readonly totalPaths?: number;
readonly traceId?: string;
}
/** Export format. */
export type GraphExportFormat = 'ndjson' | 'csv' | 'graphml' | 'png' | 'svg';
/** Graph export options. */
export interface GraphExportOptions extends GraphQueryOptions {
readonly format: GraphExportFormat;
readonly bbox?: TileBbox;
readonly includeOverlays?: boolean;
}
/** Graph export response. */
export interface GraphExportResponse {
readonly exportId: string;
readonly format: GraphExportFormat;
readonly url: string;
readonly sha256?: string;
readonly size?: number;
readonly expiresAt?: string;
readonly traceId?: string;
}
/** Asset snapshot. */
export interface AssetSnapshot {
readonly assetId: string;
readonly name: string;
readonly kind: string;
readonly components?: readonly string[];
readonly vulnerabilities?: readonly string[];
readonly snapshotAt: string;
readonly traceId?: string;
}
/** Adjacency list response. */
export interface AdjacencyResponse {
readonly nodeId: string;
readonly incoming: readonly { nodeId: string; edgeType: GraphEdgeType }[];
readonly outgoing: readonly { nodeId: string; edgeType: GraphEdgeType }[];
readonly traceId?: string;
}
/** Graph error codes. */
export type GraphErrorCode =
| 'ERR_GRAPH_NOT_FOUND'
| 'ERR_GRAPH_INVALID_BBOX'
| 'ERR_GRAPH_INVALID_ZOOM'
| 'ERR_GRAPH_TOO_LARGE'
| 'ERR_GRAPH_RATE_LIMIT'
| 'ERR_GRAPH_EXPORT_FAILED';

View File

@@ -5,8 +5,11 @@ import {
InjectionToken,
Optional,
} from '@angular/core';
import { Observable } from 'rxjs';
import { Observable, of, throwError } from 'rxjs';
import { map, catchError, delay } from 'rxjs/operators';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import {
ChannelHealthResponse,
ChannelTestSendRequest,
@@ -15,9 +18,28 @@ import {
NotifyDeliveriesQueryOptions,
NotifyDeliveriesResponse,
NotifyRule,
DigestSchedule,
DigestSchedulesResponse,
QuietHours,
QuietHoursResponse,
ThrottleConfig,
ThrottleConfigsResponse,
NotifySimulationRequest,
NotifySimulationResult,
EscalationPolicy,
EscalationPoliciesResponse,
LocalizationConfig,
LocalizationConfigsResponse,
NotifyIncident,
NotifyIncidentsResponse,
AckRequest,
AckResponse,
NotifyQueryOptions,
} from './notify.models';
import { generateTraceId } from './trace.util';
export interface NotifyApi {
// WEB-NOTIFY-38-001: Base notification APIs
listChannels(): Observable<NotifyChannel[]>;
saveChannel(channel: NotifyChannel): Observable<NotifyChannel>;
deleteChannel(channelId: string): Observable<void>;
@@ -32,6 +54,29 @@ export interface NotifyApi {
listDeliveries(
options?: NotifyDeliveriesQueryOptions
): Observable<NotifyDeliveriesResponse>;
// WEB-NOTIFY-39-001: Digest scheduling, quiet-hours, throttle management
listDigestSchedules(options?: NotifyQueryOptions): Observable<DigestSchedulesResponse>;
saveDigestSchedule(schedule: DigestSchedule): Observable<DigestSchedule>;
deleteDigestSchedule(scheduleId: string): Observable<void>;
listQuietHours(options?: NotifyQueryOptions): Observable<QuietHoursResponse>;
saveQuietHours(quietHours: QuietHours): Observable<QuietHours>;
deleteQuietHours(quietHoursId: string): Observable<void>;
listThrottleConfigs(options?: NotifyQueryOptions): Observable<ThrottleConfigsResponse>;
saveThrottleConfig(config: ThrottleConfig): Observable<ThrottleConfig>;
deleteThrottleConfig(throttleId: string): Observable<void>;
simulateNotification(request: NotifySimulationRequest, options?: NotifyQueryOptions): Observable<NotifySimulationResult>;
// WEB-NOTIFY-40-001: Escalation, localization, channel health, ack verification
listEscalationPolicies(options?: NotifyQueryOptions): Observable<EscalationPoliciesResponse>;
saveEscalationPolicy(policy: EscalationPolicy): Observable<EscalationPolicy>;
deleteEscalationPolicy(policyId: string): Observable<void>;
listLocalizations(options?: NotifyQueryOptions): Observable<LocalizationConfigsResponse>;
saveLocalization(config: LocalizationConfig): Observable<LocalizationConfig>;
deleteLocalization(localeId: string): Observable<void>;
listIncidents(options?: NotifyQueryOptions): Observable<NotifyIncidentsResponse>;
getIncident(incidentId: string, options?: NotifyQueryOptions): Observable<NotifyIncident>;
acknowledgeIncident(incidentId: string, request: AckRequest, options?: NotifyQueryOptions): Observable<AckResponse>;
}
export const NOTIFY_API = new InjectionToken<NotifyApi>('NOTIFY_API');
@@ -42,10 +87,16 @@ export const NOTIFY_API_BASE_URL = new InjectionToken<string>(
export const NOTIFY_TENANT_ID = new InjectionToken<string>('NOTIFY_TENANT_ID');
/**
* HTTP Notify Client.
* Implements WEB-NOTIFY-38-001, WEB-NOTIFY-39-001, WEB-NOTIFY-40-001.
*/
@Injectable({ providedIn: 'root' })
export class NotifyApiHttpClient implements NotifyApi {
constructor(
private readonly http: HttpClient,
private readonly authSession: AuthSessionStore,
private readonly tenantService: TenantActivationService,
@Inject(NOTIFY_API_BASE_URL) private readonly baseUrl: string,
@Optional() @Inject(NOTIFY_TENANT_ID) private readonly tenantId: string | null
) {}
@@ -131,6 +182,185 @@ export class NotifyApiHttpClient implements NotifyApi {
});
}
// WEB-NOTIFY-39-001: Digest scheduling
listDigestSchedules(options: NotifyQueryOptions = {}): Observable<DigestSchedulesResponse> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeadersWithTrace(traceId);
const params = this.buildPaginationParams(options);
return this.http.get<DigestSchedulesResponse>(`${this.baseUrl}/digest-schedules`, { headers, params }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
saveDigestSchedule(schedule: DigestSchedule): Observable<DigestSchedule> {
const traceId = generateTraceId();
const headers = this.buildHeadersWithTrace(traceId);
return this.http.post<DigestSchedule>(`${this.baseUrl}/digest-schedules`, schedule, { headers }).pipe(
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
deleteDigestSchedule(scheduleId: string): Observable<void> {
const headers = this.buildHeaders();
return this.http.delete<void>(`${this.baseUrl}/digest-schedules/${encodeURIComponent(scheduleId)}`, { headers });
}
// WEB-NOTIFY-39-001: Quiet hours
listQuietHours(options: NotifyQueryOptions = {}): Observable<QuietHoursResponse> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeadersWithTrace(traceId);
const params = this.buildPaginationParams(options);
return this.http.get<QuietHoursResponse>(`${this.baseUrl}/quiet-hours`, { headers, params }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
saveQuietHours(quietHours: QuietHours): Observable<QuietHours> {
const traceId = generateTraceId();
const headers = this.buildHeadersWithTrace(traceId);
return this.http.post<QuietHours>(`${this.baseUrl}/quiet-hours`, quietHours, { headers }).pipe(
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
deleteQuietHours(quietHoursId: string): Observable<void> {
const headers = this.buildHeaders();
return this.http.delete<void>(`${this.baseUrl}/quiet-hours/${encodeURIComponent(quietHoursId)}`, { headers });
}
// WEB-NOTIFY-39-001: Throttle configs
listThrottleConfigs(options: NotifyQueryOptions = {}): Observable<ThrottleConfigsResponse> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeadersWithTrace(traceId);
const params = this.buildPaginationParams(options);
return this.http.get<ThrottleConfigsResponse>(`${this.baseUrl}/throttle-configs`, { headers, params }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
saveThrottleConfig(config: ThrottleConfig): Observable<ThrottleConfig> {
const traceId = generateTraceId();
const headers = this.buildHeadersWithTrace(traceId);
return this.http.post<ThrottleConfig>(`${this.baseUrl}/throttle-configs`, config, { headers }).pipe(
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
deleteThrottleConfig(throttleId: string): Observable<void> {
const headers = this.buildHeaders();
return this.http.delete<void>(`${this.baseUrl}/throttle-configs/${encodeURIComponent(throttleId)}`, { headers });
}
// WEB-NOTIFY-39-001: Simulation
simulateNotification(request: NotifySimulationRequest, options: NotifyQueryOptions = {}): Observable<NotifySimulationResult> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeadersWithTrace(traceId);
return this.http.post<NotifySimulationResult>(`${this.baseUrl}/simulate`, request, { headers }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
// WEB-NOTIFY-40-001: Escalation policies
listEscalationPolicies(options: NotifyQueryOptions = {}): Observable<EscalationPoliciesResponse> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeadersWithTrace(traceId);
const params = this.buildPaginationParams(options);
return this.http.get<EscalationPoliciesResponse>(`${this.baseUrl}/escalation-policies`, { headers, params }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
saveEscalationPolicy(policy: EscalationPolicy): Observable<EscalationPolicy> {
const traceId = generateTraceId();
const headers = this.buildHeadersWithTrace(traceId);
return this.http.post<EscalationPolicy>(`${this.baseUrl}/escalation-policies`, policy, { headers }).pipe(
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
deleteEscalationPolicy(policyId: string): Observable<void> {
const headers = this.buildHeaders();
return this.http.delete<void>(`${this.baseUrl}/escalation-policies/${encodeURIComponent(policyId)}`, { headers });
}
// WEB-NOTIFY-40-001: Localization
listLocalizations(options: NotifyQueryOptions = {}): Observable<LocalizationConfigsResponse> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeadersWithTrace(traceId);
const params = this.buildPaginationParams(options);
return this.http.get<LocalizationConfigsResponse>(`${this.baseUrl}/localizations`, { headers, params }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
saveLocalization(config: LocalizationConfig): Observable<LocalizationConfig> {
const traceId = generateTraceId();
const headers = this.buildHeadersWithTrace(traceId);
return this.http.post<LocalizationConfig>(`${this.baseUrl}/localizations`, config, { headers }).pipe(
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
deleteLocalization(localeId: string): Observable<void> {
const headers = this.buildHeaders();
return this.http.delete<void>(`${this.baseUrl}/localizations/${encodeURIComponent(localeId)}`, { headers });
}
// WEB-NOTIFY-40-001: Incidents and acknowledgment
listIncidents(options: NotifyQueryOptions = {}): Observable<NotifyIncidentsResponse> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeadersWithTrace(traceId);
const params = this.buildPaginationParams(options);
return this.http.get<NotifyIncidentsResponse>(`${this.baseUrl}/incidents`, { headers, params }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getIncident(incidentId: string, options: NotifyQueryOptions = {}): Observable<NotifyIncident> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeadersWithTrace(traceId);
return this.http.get<NotifyIncident>(
`${this.baseUrl}/incidents/${encodeURIComponent(incidentId)}`,
{ headers }
).pipe(
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
acknowledgeIncident(incidentId: string, request: AckRequest, options: NotifyQueryOptions = {}): Observable<AckResponse> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeadersWithTrace(traceId);
return this.http.post<AckResponse>(
`${this.baseUrl}/incidents/${encodeURIComponent(incidentId)}/ack`,
request,
{ headers }
).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
private buildHeaders(): HttpHeaders {
if (!this.tenantId) {
return new HttpHeaders();
@@ -138,5 +368,356 @@ export class NotifyApiHttpClient implements NotifyApi {
return new HttpHeaders({ 'X-StellaOps-Tenant': this.tenantId });
}
private buildHeadersWithTrace(traceId: string): HttpHeaders {
const tenant = this.tenantId || this.authSession.getActiveTenantId() || '';
return new HttpHeaders({
'X-StellaOps-Tenant': tenant,
'X-Stella-Trace-Id': traceId,
'X-Stella-Request-Id': traceId,
Accept: 'application/json',
});
}
private buildPaginationParams(options: NotifyQueryOptions): HttpParams {
let params = new HttpParams();
if (options.pageToken) {
params = params.set('pageToken', options.pageToken);
}
if (options.pageSize) {
params = params.set('pageSize', String(options.pageSize));
}
return params;
}
private mapError(err: unknown, traceId: string): Error {
if (err instanceof Error) {
return new Error(`[${traceId}] Notify error: ${err.message}`);
}
return new Error(`[${traceId}] Notify error: Unknown error`);
}
}
/**
* Mock Notify Client for quickstart mode.
* Implements WEB-NOTIFY-38-001, WEB-NOTIFY-39-001, WEB-NOTIFY-40-001.
*/
@Injectable({ providedIn: 'root' })
export class MockNotifyClient implements NotifyApi {
private readonly mockChannels: NotifyChannel[] = [
{
channelId: 'chn-soc-webhook',
tenantId: 'tenant-default',
name: 'SOC Webhook',
displayName: 'Security Operations Center',
type: 'Webhook',
enabled: true,
config: {
secretRef: 'secret://notify/soc-webhook',
endpoint: 'https://soc.example.com/webhooks/stellaops',
},
createdAt: '2025-10-01T00:00:00Z',
},
{
channelId: 'chn-slack-dev',
tenantId: 'tenant-default',
name: 'Slack Dev',
displayName: 'Development Team Slack',
type: 'Slack',
enabled: true,
config: {
secretRef: 'secret://notify/slack-dev',
target: '#dev-alerts',
},
createdAt: '2025-10-01T00:00:00Z',
},
];
private readonly mockRules: NotifyRule[] = [
{
ruleId: 'rule-critical-vulns',
tenantId: 'tenant-default',
name: 'Critical Vulnerabilities',
enabled: true,
match: { minSeverity: 'critical', kevOnly: true },
actions: [
{ actionId: 'act-soc', channel: 'chn-soc-webhook', digest: 'instant', enabled: true },
],
createdAt: '2025-10-01T00:00:00Z',
},
];
private readonly mockDigestSchedules: DigestSchedule[] = [
{
scheduleId: 'digest-daily',
tenantId: 'tenant-default',
name: 'Daily Digest',
frequency: 'daily',
timezone: 'UTC',
hour: 8,
enabled: true,
createdAt: '2025-10-01T00:00:00Z',
},
];
private readonly mockQuietHours: QuietHours[] = [
{
quietHoursId: 'qh-default',
tenantId: 'tenant-default',
name: 'Weeknight Quiet',
windows: [
{ timezone: 'UTC', days: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'], start: '22:00', end: '06:00' },
],
exemptions: [
{ eventKinds: ['attestor.verification.failed'], reason: 'Always alert on attestation failures' },
],
enabled: true,
createdAt: '2025-10-01T00:00:00Z',
},
];
private readonly mockThrottleConfigs: ThrottleConfig[] = [
{
throttleId: 'throttle-default',
tenantId: 'tenant-default',
name: 'Default Throttle',
windowSeconds: 60,
maxEvents: 50,
burstLimit: 100,
enabled: true,
createdAt: '2025-10-01T00:00:00Z',
},
];
private readonly mockEscalationPolicies: EscalationPolicy[] = [
{
policyId: 'escalate-critical',
tenantId: 'tenant-default',
name: 'Critical Escalation',
levels: [
{ level: 1, delayMinutes: 0, channels: ['chn-soc-webhook'], notifyOnAck: false },
{ level: 2, delayMinutes: 15, channels: ['chn-slack-dev'], notifyOnAck: true },
],
enabled: true,
createdAt: '2025-10-01T00:00:00Z',
},
];
private readonly mockLocalizations: LocalizationConfig[] = [
{
localeId: 'loc-en-us',
tenantId: 'tenant-default',
locale: 'en-US',
name: 'English (US)',
templates: { 'vuln.critical': 'Critical vulnerability detected: {{title}}' },
dateFormat: 'MM/DD/YYYY',
timeFormat: 'HH:mm:ss',
enabled: true,
createdAt: '2025-10-01T00:00:00Z',
},
];
private readonly mockIncidents: NotifyIncident[] = [
{
incidentId: 'inc-001',
tenantId: 'tenant-default',
title: 'Critical vulnerability CVE-2021-44228',
severity: 'critical',
status: 'open',
eventIds: ['evt-001', 'evt-002'],
escalationLevel: 1,
escalationPolicyId: 'escalate-critical',
createdAt: '2025-12-10T10:00:00Z',
},
];
// WEB-NOTIFY-38-001: Base APIs
listChannels(): Observable<NotifyChannel[]> {
return of([...this.mockChannels]).pipe(delay(50));
}
saveChannel(channel: NotifyChannel): Observable<NotifyChannel> {
return of(channel).pipe(delay(50));
}
deleteChannel(_channelId: string): Observable<void> {
return of(undefined).pipe(delay(50));
}
getChannelHealth(channelId: string): Observable<ChannelHealthResponse> {
return of({
tenantId: 'tenant-default',
channelId,
status: 'Healthy' as const,
checkedAt: new Date().toISOString(),
traceId: generateTraceId(),
}).pipe(delay(50));
}
testChannel(channelId: string, payload: ChannelTestSendRequest): Observable<ChannelTestSendResponse> {
return of({
tenantId: 'tenant-default',
channelId,
preview: {
channelType: 'Webhook' as const,
format: 'Json' as const,
target: 'https://soc.example.com/webhooks/stellaops',
title: payload.title || 'Test notification',
body: payload.body || 'Test notification body',
},
queuedAt: new Date().toISOString(),
traceId: generateTraceId(),
}).pipe(delay(100));
}
listRules(): Observable<NotifyRule[]> {
return of([...this.mockRules]).pipe(delay(50));
}
saveRule(rule: NotifyRule): Observable<NotifyRule> {
return of(rule).pipe(delay(50));
}
deleteRule(_ruleId: string): Observable<void> {
return of(undefined).pipe(delay(50));
}
listDeliveries(_options?: NotifyDeliveriesQueryOptions): Observable<NotifyDeliveriesResponse> {
return of({ items: [], count: 0 }).pipe(delay(50));
}
// WEB-NOTIFY-39-001: Digest, quiet hours, throttle
listDigestSchedules(options: NotifyQueryOptions = {}): Observable<DigestSchedulesResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
items: [...this.mockDigestSchedules],
total: this.mockDigestSchedules.length,
traceId,
}).pipe(delay(50));
}
saveDigestSchedule(schedule: DigestSchedule): Observable<DigestSchedule> {
return of(schedule).pipe(delay(50));
}
deleteDigestSchedule(_scheduleId: string): Observable<void> {
return of(undefined).pipe(delay(50));
}
listQuietHours(options: NotifyQueryOptions = {}): Observable<QuietHoursResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
items: [...this.mockQuietHours],
total: this.mockQuietHours.length,
traceId,
}).pipe(delay(50));
}
saveQuietHours(quietHours: QuietHours): Observable<QuietHours> {
return of(quietHours).pipe(delay(50));
}
deleteQuietHours(_quietHoursId: string): Observable<void> {
return of(undefined).pipe(delay(50));
}
listThrottleConfigs(options: NotifyQueryOptions = {}): Observable<ThrottleConfigsResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
items: [...this.mockThrottleConfigs],
total: this.mockThrottleConfigs.length,
traceId,
}).pipe(delay(50));
}
saveThrottleConfig(config: ThrottleConfig): Observable<ThrottleConfig> {
return of(config).pipe(delay(50));
}
deleteThrottleConfig(_throttleId: string): Observable<void> {
return of(undefined).pipe(delay(50));
}
simulateNotification(request: NotifySimulationRequest, options: NotifyQueryOptions = {}): Observable<NotifySimulationResult> {
const traceId = options.traceId ?? generateTraceId();
return of({
simulationId: `sim-${Date.now()}`,
matchedRules: ['rule-critical-vulns'],
wouldNotify: [
{
channelId: 'chn-soc-webhook',
actionId: 'act-soc',
template: 'tmpl-default',
digest: 'instant' as const,
},
],
throttled: false,
quietHoursActive: false,
traceId,
}).pipe(delay(100));
}
// WEB-NOTIFY-40-001: Escalation, localization, incidents
listEscalationPolicies(options: NotifyQueryOptions = {}): Observable<EscalationPoliciesResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
items: [...this.mockEscalationPolicies],
total: this.mockEscalationPolicies.length,
traceId,
}).pipe(delay(50));
}
saveEscalationPolicy(policy: EscalationPolicy): Observable<EscalationPolicy> {
return of(policy).pipe(delay(50));
}
deleteEscalationPolicy(_policyId: string): Observable<void> {
return of(undefined).pipe(delay(50));
}
listLocalizations(options: NotifyQueryOptions = {}): Observable<LocalizationConfigsResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
items: [...this.mockLocalizations],
total: this.mockLocalizations.length,
traceId,
}).pipe(delay(50));
}
saveLocalization(config: LocalizationConfig): Observable<LocalizationConfig> {
return of(config).pipe(delay(50));
}
deleteLocalization(_localeId: string): Observable<void> {
return of(undefined).pipe(delay(50));
}
listIncidents(options: NotifyQueryOptions = {}): Observable<NotifyIncidentsResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
items: [...this.mockIncidents],
total: this.mockIncidents.length,
traceId,
}).pipe(delay(50));
}
getIncident(incidentId: string, _options: NotifyQueryOptions = {}): Observable<NotifyIncident> {
const incident = this.mockIncidents.find((i) => i.incidentId === incidentId);
if (!incident) {
return throwError(() => new Error(`Incident not found: ${incidentId}`));
}
return of(incident).pipe(delay(50));
}
acknowledgeIncident(incidentId: string, _request: AckRequest, options: NotifyQueryOptions = {}): Observable<AckResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
incidentId,
acknowledged: true,
acknowledgedAt: new Date().toISOString(),
acknowledgedBy: 'user@example.com',
traceId,
}).pipe(delay(100));
}
}

View File

@@ -192,3 +192,228 @@ export interface ChannelTestSendResponse {
readonly metadata?: Record<string, string>;
}
/**
* WEB-NOTIFY-39-001: Digest scheduling, quiet-hours, throttle management.
*/
/** Digest frequency. */
export type DigestFrequency = 'instant' | 'hourly' | 'daily' | 'weekly';
/** Digest schedule. */
export interface DigestSchedule {
readonly scheduleId: string;
readonly tenantId: string;
readonly name: string;
readonly description?: string;
readonly frequency: DigestFrequency;
readonly timezone: string;
readonly hour?: number;
readonly dayOfWeek?: number;
readonly enabled: boolean;
readonly createdAt: string;
readonly updatedAt?: string;
}
/** Digest schedules response. */
export interface DigestSchedulesResponse {
readonly items: readonly DigestSchedule[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Quiet hour window. */
export interface QuietHourWindow {
readonly timezone: string;
readonly days: readonly string[];
readonly start: string;
readonly end: string;
}
/** Quiet hour exemption. */
export interface QuietHourExemption {
readonly eventKinds: readonly string[];
readonly reason: string;
}
/** Quiet hours configuration. */
export interface QuietHours {
readonly quietHoursId: string;
readonly tenantId: string;
readonly name: string;
readonly description?: string;
readonly windows: readonly QuietHourWindow[];
readonly exemptions?: readonly QuietHourExemption[];
readonly enabled: boolean;
readonly createdAt: string;
readonly updatedAt?: string;
}
/** Quiet hours response. */
export interface QuietHoursResponse {
readonly items: readonly QuietHours[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Throttle configuration. */
export interface ThrottleConfig {
readonly throttleId: string;
readonly tenantId: string;
readonly name: string;
readonly description?: string;
readonly windowSeconds: number;
readonly maxEvents: number;
readonly burstLimit?: number;
readonly enabled: boolean;
readonly createdAt: string;
readonly updatedAt?: string;
}
/** Throttle configs response. */
export interface ThrottleConfigsResponse {
readonly items: readonly ThrottleConfig[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Simulation request. */
export interface NotifySimulationRequest {
readonly eventKind: string;
readonly payload: Record<string, unknown>;
readonly targetChannels?: readonly string[];
readonly dryRun: boolean;
}
/** Simulation result. */
export interface NotifySimulationResult {
readonly simulationId: string;
readonly matchedRules: readonly string[];
readonly wouldNotify: readonly {
readonly channelId: string;
readonly actionId: string;
readonly template: string;
readonly digest: DigestFrequency;
}[];
readonly throttled: boolean;
readonly quietHoursActive: boolean;
readonly traceId?: string;
}
/**
* WEB-NOTIFY-40-001: Escalation, localization, channel health, ack verification.
*/
/** Escalation policy. */
export interface EscalationPolicy {
readonly policyId: string;
readonly tenantId: string;
readonly name: string;
readonly description?: string;
readonly levels: readonly EscalationLevel[];
readonly enabled: boolean;
readonly createdAt: string;
readonly updatedAt?: string;
}
/** Escalation level. */
export interface EscalationLevel {
readonly level: number;
readonly delayMinutes: number;
readonly channels: readonly string[];
readonly notifyOnAck: boolean;
}
/** Escalation policies response. */
export interface EscalationPoliciesResponse {
readonly items: readonly EscalationPolicy[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Localization config. */
export interface LocalizationConfig {
readonly localeId: string;
readonly tenantId: string;
readonly locale: string;
readonly name: string;
readonly templates: Record<string, string>;
readonly dateFormat?: string;
readonly timeFormat?: string;
readonly timezone?: string;
readonly enabled: boolean;
readonly createdAt: string;
readonly updatedAt?: string;
}
/** Localization configs response. */
export interface LocalizationConfigsResponse {
readonly items: readonly LocalizationConfig[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Incident for acknowledgment. */
export interface NotifyIncident {
readonly incidentId: string;
readonly tenantId: string;
readonly title: string;
readonly severity: 'critical' | 'high' | 'medium' | 'low' | 'info';
readonly status: 'open' | 'acknowledged' | 'resolved' | 'closed';
readonly eventIds: readonly string[];
readonly escalationLevel?: number;
readonly escalationPolicyId?: string;
readonly assignee?: string;
readonly acknowledgedAt?: string;
readonly acknowledgedBy?: string;
readonly resolvedAt?: string;
readonly resolvedBy?: string;
readonly createdAt: string;
readonly updatedAt?: string;
}
/** Incidents response. */
export interface NotifyIncidentsResponse {
readonly items: readonly NotifyIncident[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Acknowledgment request. */
export interface AckRequest {
readonly ackToken: string;
readonly note?: string;
}
/** Acknowledgment response. */
export interface AckResponse {
readonly incidentId: string;
readonly acknowledged: boolean;
readonly acknowledgedAt: string;
readonly acknowledgedBy: string;
readonly traceId?: string;
}
/** Notify query options. */
export interface NotifyQueryOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly pageToken?: string;
readonly pageSize?: number;
readonly traceId?: string;
}
/** Notify error codes. */
export type NotifyErrorCode =
| 'ERR_NOTIFY_CHANNEL_NOT_FOUND'
| 'ERR_NOTIFY_RULE_NOT_FOUND'
| 'ERR_NOTIFY_INVALID_CONFIG'
| 'ERR_NOTIFY_RATE_LIMIT'
| 'ERR_NOTIFY_ACK_INVALID'
| 'ERR_NOTIFY_ACK_EXPIRED';