using StellaOps.Auth.ServerIntegration; using StellaOps.Graph.Api.Contracts; using StellaOps.Graph.Api.Services; var builder = WebApplication.CreateBuilder(args); builder.Services.AddMemoryCache(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(_ => new RateLimiterService(limitPerWindow: 120)); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddScoped(); builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration); builder.TryAddStellaOpsLocalBinding("graph"); var app = builder.Build(); app.LogStellaOpsLocalHostname("graph"); app.UseStellaOpsCors(); app.UseRouting(); app.MapPost("/graph/search", async (HttpContext context, GraphSearchRequest request, IGraphSearchService service, CancellationToken ct) => { var sw = System.Diagnostics.Stopwatch.StartNew(); context.Response.ContentType = "application/x-ndjson"; var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault(); if (string.IsNullOrWhiteSpace(tenant)) { await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct); return Results.Empty; } if (!context.Request.Headers.ContainsKey("Authorization")) { await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct); return Results.Empty; } if (!RateLimit(context, "/graph/search")) { await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct); LogAudit(context, "/graph/search", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds); return Results.Empty; } var scopes = context.Request.Headers["X-Stella-Scopes"] .SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty()) .ToHashSet(StringComparer.OrdinalIgnoreCase); if (!scopes.Contains("graph:read") && !scopes.Contains("graph:query")) { await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:read or graph:query scope", ct); return Results.Empty; } var validation = SearchValidator.Validate(request); if (validation is not null) { await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", validation, ct); LogAudit(context, "/graph/search", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds); return Results.Empty; } var tenantId = tenant!; await foreach (var line in service.SearchAsync(tenantId, request, ct)) { await context.Response.WriteAsync(line, ct); await context.Response.WriteAsync("\n", ct); await context.Response.Body.FlushAsync(ct); } LogAudit(context, "/graph/search", StatusCodes.Status200OK, sw.ElapsedMilliseconds); return Results.Empty; }); app.MapPost("/graph/query", async (HttpContext context, GraphQueryRequest request, IGraphQueryService service, CancellationToken ct) => { var sw = System.Diagnostics.Stopwatch.StartNew(); context.Response.ContentType = "application/x-ndjson"; var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault(); if (string.IsNullOrWhiteSpace(tenant)) { await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct); return Results.Empty; } if (!context.Request.Headers.ContainsKey("Authorization")) { await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct); return Results.Empty; } if (!RateLimit(context, "/graph/query")) { await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct); LogAudit(context, "/graph/query", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds); return Results.Empty; } var scopes = context.Request.Headers["X-Stella-Scopes"] .SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty()) .ToHashSet(StringComparer.OrdinalIgnoreCase); if (!scopes.Contains("graph:query")) { await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:query scope", ct); return Results.Empty; } var validation = QueryValidator.Validate(request); if (validation is not null) { await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", validation, ct); LogAudit(context, "/graph/query", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds); return Results.Empty; } var tenantId = tenant!; await foreach (var line in service.QueryAsync(tenantId, request, ct)) { await context.Response.WriteAsync(line, ct); await context.Response.WriteAsync("\n", ct); await context.Response.Body.FlushAsync(ct); } LogAudit(context, "/graph/query", StatusCodes.Status200OK, sw.ElapsedMilliseconds); return Results.Empty; }); app.MapPost("/graph/paths", async (HttpContext context, GraphPathRequest request, IGraphPathService service, CancellationToken ct) => { var sw = System.Diagnostics.Stopwatch.StartNew(); context.Response.ContentType = "application/x-ndjson"; var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault(); if (string.IsNullOrWhiteSpace(tenant)) { await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct); return Results.Empty; } if (!context.Request.Headers.ContainsKey("Authorization")) { await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct); return Results.Empty; } if (!RateLimit(context, "/graph/paths")) { await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct); LogAudit(context, "/graph/paths", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds); return Results.Empty; } var scopes = context.Request.Headers["X-Stella-Scopes"] .SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty()) .ToHashSet(StringComparer.OrdinalIgnoreCase); if (!scopes.Contains("graph:query")) { await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:query scope", ct); return Results.Empty; } var validation = PathValidator.Validate(request); if (validation is not null) { await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", validation, ct); LogAudit(context, "/graph/paths", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds); return Results.Empty; } var tenantId = tenant!; await foreach (var line in service.FindPathsAsync(tenantId, request, ct)) { await context.Response.WriteAsync(line, ct); await context.Response.WriteAsync("\n", ct); await context.Response.Body.FlushAsync(ct); } LogAudit(context, "/graph/paths", StatusCodes.Status200OK, sw.ElapsedMilliseconds); return Results.Empty; }); app.MapPost("/graph/diff", async (HttpContext context, GraphDiffRequest request, IGraphDiffService service, CancellationToken ct) => { var sw = System.Diagnostics.Stopwatch.StartNew(); context.Response.ContentType = "application/x-ndjson"; var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault(); if (string.IsNullOrWhiteSpace(tenant)) { await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct); return Results.Empty; } if (!context.Request.Headers.ContainsKey("Authorization")) { await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct); return Results.Empty; } if (!RateLimit(context, "/graph/diff")) { await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct); LogAudit(context, "/graph/diff", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds); return Results.Empty; } var scopes = context.Request.Headers["X-Stella-Scopes"] .SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty()) .ToHashSet(StringComparer.OrdinalIgnoreCase); if (!scopes.Contains("graph:query")) { await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:query scope", ct); return Results.Empty; } var validation = DiffValidator.Validate(request); if (validation is not null) { await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", validation, ct); LogAudit(context, "/graph/diff", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds); return Results.Empty; } var tenantId = tenant!; await foreach (var line in service.DiffAsync(tenantId, request, ct)) { await context.Response.WriteAsync(line, ct); await context.Response.WriteAsync("\n", ct); await context.Response.Body.FlushAsync(ct); } LogAudit(context, "/graph/diff", StatusCodes.Status200OK, sw.ElapsedMilliseconds); return Results.Empty; }); app.MapPost("/graph/lineage", async (HttpContext context, GraphLineageRequest request, IGraphLineageService service, CancellationToken ct) => { var sw = System.Diagnostics.Stopwatch.StartNew(); var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault(); if (string.IsNullOrWhiteSpace(tenant)) { await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct); return Results.Empty; } if (!context.Request.Headers.ContainsKey("Authorization")) { await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct); return Results.Empty; } if (!RateLimit(context, "/graph/lineage")) { await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct); LogAudit(context, "/graph/lineage", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds); return Results.Empty; } var scopes = context.Request.Headers["X-Stella-Scopes"] .SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty()) .ToHashSet(StringComparer.OrdinalIgnoreCase); if (!scopes.Contains("graph:read") && !scopes.Contains("graph:query")) { await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:read or graph:query scope", ct); return Results.Empty; } var validation = LineageValidator.Validate(request); if (validation is not null) { await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", validation, ct); LogAudit(context, "/graph/lineage", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds); return Results.Empty; } var tenantId = tenant!; var response = await service.GetLineageAsync(tenantId, request, ct); LogAudit(context, "/graph/lineage", StatusCodes.Status200OK, sw.ElapsedMilliseconds); return Results.Ok(response); }); app.MapPost("/graph/export", async (HttpContext context, GraphExportRequest request, IGraphExportService service, CancellationToken ct) => { var sw = System.Diagnostics.Stopwatch.StartNew(); var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault(); if (string.IsNullOrWhiteSpace(tenant)) { await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct); LogAudit(context, "/graph/export", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds); return Results.Empty; } if (!context.Request.Headers.ContainsKey("Authorization")) { await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct); return Results.Empty; } var scopes = context.Request.Headers["X-Stella-Scopes"] .SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty()) .ToHashSet(StringComparer.OrdinalIgnoreCase); if (!scopes.Contains("graph:export")) { await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:export scope", ct); return Results.Empty; } if (!RateLimit(context, "/graph/export")) { await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct); LogAudit(context, "/graph/export", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds); return Results.Empty; } var validation = ExportValidator.Validate(request); if (validation is not null) { await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", validation, ct); LogAudit(context, "/graph/export", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds); return Results.Empty; } var tenantId = tenant!; var job = await service.StartExportAsync(tenantId, request, ct); var manifest = new { jobId = job.JobId, status = "completed", format = job.Format, sha256 = job.Sha256, size = job.SizeBytes, downloadUrl = $"/graph/export/{job.JobId}", completedAt = job.CompletedAt }; LogAudit(context, "/graph/export", StatusCodes.Status200OK, sw.ElapsedMilliseconds); return Results.Ok(manifest); }); app.MapGet("/graph/export/{jobId}", (string jobId, HttpContext context, IGraphExportService service) => { var job = service.Get(jobId); if (job is null) { return Results.NotFound(new ErrorResponse { Error = "GRAPH_EXPORT_NOT_FOUND", Message = "Export job not found" }); } context.Response.Headers.ContentLength = job.Payload.Length; context.Response.Headers["X-Content-SHA256"] = job.Sha256; return Results.File(job.Payload, job.ContentType, $"graph-export-{job.JobId}.{job.Format}"); }); // ──────────────────────────────────────────────────────────────────────────────── // Edge Metadata API // ──────────────────────────────────────────────────────────────────────────────── app.MapPost("/graph/edges/metadata", async (EdgeMetadataRequest request, HttpContext context, IEdgeMetadataService service, CancellationToken ct) => { var sw = System.Diagnostics.Stopwatch.StartNew(); var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "default"; if (!RateLimit(context, "/graph/edges/metadata")) { LogAudit(context, "/graph/edges/metadata", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds); return Results.StatusCode(StatusCodes.Status429TooManyRequests); } var response = await service.GetEdgeMetadataAsync(tenant, request, ct); LogAudit(context, "/graph/edges/metadata", StatusCodes.Status200OK, sw.ElapsedMilliseconds); return Results.Ok(response); }); app.MapGet("/graph/edges/{edgeId}/metadata", async (string edgeId, HttpContext context, IEdgeMetadataService service, CancellationToken ct) => { var sw = System.Diagnostics.Stopwatch.StartNew(); var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "default"; if (!RateLimit(context, "/graph/edges/metadata")) { LogAudit(context, "/graph/edges/metadata", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds); return Results.StatusCode(StatusCodes.Status429TooManyRequests); } var result = await service.GetSingleEdgeMetadataAsync(tenant, edgeId, ct); if (result is null) { LogAudit(context, "/graph/edges/metadata", StatusCodes.Status404NotFound, sw.ElapsedMilliseconds); return Results.NotFound(new ErrorResponse { Error = "EDGE_NOT_FOUND", Message = $"Edge '{edgeId}' not found" }); } LogAudit(context, "/graph/edges/metadata", StatusCodes.Status200OK, sw.ElapsedMilliseconds); return Results.Ok(result); }); app.MapGet("/graph/edges/path/{sourceNodeId}/{targetNodeId}", async (string sourceNodeId, string targetNodeId, HttpContext context, IEdgeMetadataService service, CancellationToken ct) => { var sw = System.Diagnostics.Stopwatch.StartNew(); var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "default"; if (!RateLimit(context, "/graph/edges/path")) { LogAudit(context, "/graph/edges/path", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds); return Results.StatusCode(StatusCodes.Status429TooManyRequests); } var edges = await service.GetPathEdgesWithMetadataAsync(tenant, sourceNodeId, targetNodeId, ct); LogAudit(context, "/graph/edges/path", StatusCodes.Status200OK, sw.ElapsedMilliseconds); return Results.Ok(new { sourceNodeId, targetNodeId, edges = edges.ToList() }); }); app.MapGet("/graph/edges/by-reason/{reason}", async (string reason, int? limit, string? cursor, HttpContext context, IEdgeMetadataService service, CancellationToken ct) => { var sw = System.Diagnostics.Stopwatch.StartNew(); var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "default"; if (!RateLimit(context, "/graph/edges/by-reason")) { LogAudit(context, "/graph/edges/by-reason", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds); return Results.StatusCode(StatusCodes.Status429TooManyRequests); } if (!Enum.TryParse(reason, ignoreCase: true, out var edgeReason)) { LogAudit(context, "/graph/edges/by-reason", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds); return Results.BadRequest(new ErrorResponse { Error = "INVALID_REASON", Message = $"Unknown edge reason: {reason}" }); } var response = await service.QueryByReasonAsync(tenant, edgeReason, limit ?? 100, cursor, ct); LogAudit(context, "/graph/edges/by-reason", StatusCodes.Status200OK, sw.ElapsedMilliseconds); return Results.Ok(response); }); app.MapGet("/graph/edges/by-evidence", async (string evidenceType, string evidenceRef, HttpContext context, IEdgeMetadataService service, CancellationToken ct) => { var sw = System.Diagnostics.Stopwatch.StartNew(); var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "default"; if (!RateLimit(context, "/graph/edges/by-evidence")) { LogAudit(context, "/graph/edges/by-evidence", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds); return Results.StatusCode(StatusCodes.Status429TooManyRequests); } var edges = await service.QueryByEvidenceAsync(tenant, evidenceType, evidenceRef, ct); LogAudit(context, "/graph/edges/by-evidence", StatusCodes.Status200OK, sw.ElapsedMilliseconds); return Results.Ok(new { evidenceType, evidenceRef, edges = edges.ToList() }); }); app.MapGet("/healthz", () => Results.Ok(new { status = "ok" })); app.Run(); static async Task WriteError(HttpContext ctx, int status, string code, string message, CancellationToken ct) { ctx.Response.StatusCode = status; var payload = System.Text.Json.JsonSerializer.Serialize(new ErrorResponse { Error = code, Message = message }); await ctx.Response.WriteAsync(payload + "\n", ct); } static bool RateLimit(HttpContext ctx, string route) { var limiter = ctx.RequestServices.GetRequiredService(); var tenant = ctx.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "unknown"; return limiter.Allow(tenant, route); } static void LogAudit(HttpContext ctx, string route, int statusCode, long durationMs) { var logger = ctx.RequestServices.GetRequiredService(); var tenant = ctx.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "unknown"; var actor = ctx.Request.Headers["Authorization"].FirstOrDefault() ?? "anonymous"; var scopes = ctx.Request.Headers["X-Stella-Scopes"] .SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty()) .ToArray(); logger.Log(new AuditEvent( Timestamp: DateTimeOffset.UtcNow, Tenant: tenant, Route: route, Method: ctx.Request.Method, Actor: actor, Scopes: scopes, StatusCode: statusCode, DurationMs: durationMs)); }