Files
git.stella-ops.org/src/SbomService/StellaOps.SbomService/Program.cs
StellaOps Bot 4dc7cf834a
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Console CI / console-ci (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
VEX Proof Bundles / verify-bundles (push) Has been cancelled
Add sample proof bundle configurations and verification script
- Introduced sample proof bundle configuration files for testing, including `sample-proof-bundle-config.dsse.json`, `sample-proof-bundle.dsse.json`, and `sample-proof-bundle.json`.
- Implemented a verification script `test_verify_sample.sh` to validate proof bundles against specified schemas and catalogs.
- Updated existing proof bundle configurations with new metadata, including versioning, created timestamps, and justification details.
- Enhanced evidence entries with expiration dates and hashes for better integrity checks.
- Ensured all new configurations adhere to the defined schema for consistency and reliability in testing.
2025-12-04 08:54:32 +02:00

674 lines
24 KiB
C#

using System.Globalization;
using System.Diagnostics.Metrics;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StellaOps.SbomService.Models;
using StellaOps.SbomService.Options;
using StellaOps.SbomService.Services;
using StellaOps.SbomService.Observability;
using StellaOps.SbomService.Repositories;
using System.Text.Json;
using System.Diagnostics;
var builder = WebApplication.CreateBuilder(args);
builder.Configuration
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
.AddEnvironmentVariables("SBOM_");
builder.Services.AddOptions();
builder.Services.AddLogging();
var mongoSection = builder.Configuration.GetSection("SbomService:Mongo");
builder.Services.Configure<SbomMongoOptions>(mongoSection);
var mongoConnectionString = mongoSection.GetValue<string>("ConnectionString");
var mongoConfigured = !string.IsNullOrWhiteSpace(mongoConnectionString);
// Register SBOM query services (Mongo when configured; otherwise file-backed fixtures when present; fallback to in-memory seeds).
if (mongoConfigured)
{
builder.Services.AddSingleton<IMongoClient>(sp =>
{
var options = sp.GetRequiredService<IOptions<SbomMongoOptions>>().Value;
var url = new MongoUrl(options.ConnectionString!);
var settings = MongoClientSettings.FromUrl(url);
settings.ServerSelectionTimeout = TimeSpan.FromSeconds(5);
settings.RetryWrites = false;
return new MongoClient(settings);
});
builder.Services.AddSingleton<IMongoDatabase>(sp =>
{
var options = sp.GetRequiredService<IOptions<SbomMongoOptions>>().Value;
var client = sp.GetRequiredService<IMongoClient>();
var url = new MongoUrl(options.ConnectionString!);
var databaseName = string.IsNullOrWhiteSpace(options.Database)
? url.DatabaseName ?? "sbom_service"
: options.Database;
return client.GetDatabase(databaseName);
});
builder.Services.AddSingleton<IComponentLookupRepository, MongoComponentLookupRepository>();
builder.Services.AddSingleton<ICatalogRepository, MongoCatalogRepository>();
}
else
{
builder.Services.AddSingleton<IComponentLookupRepository>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
var env = sp.GetRequiredService<IHostEnvironment>();
var configured = config.GetValue<string>("SbomService:ComponentLookupPath");
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
{
return new FileComponentLookupRepository(configured!);
}
var candidate = FindFixture(env, "component_lookup.json");
return candidate is not null
? new FileComponentLookupRepository(candidate)
: new InMemoryComponentLookupRepository();
});
builder.Services.AddSingleton<ICatalogRepository>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
var env = sp.GetRequiredService<IHostEnvironment>();
var configured = config.GetValue<string>("SbomService:CatalogPath");
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
{
return new FileCatalogRepository(configured!);
}
var candidate = FindFixture(env, "catalog.json");
return candidate is not null
? new FileCatalogRepository(candidate)
: new InMemoryCatalogRepository();
});
}
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddSingleton<ISbomEventStore, InMemorySbomEventStore>();
builder.Services.AddSingleton<ISbomEventPublisher>(sp => sp.GetRequiredService<ISbomEventStore>());
builder.Services.AddSingleton<ISbomQueryService, InMemorySbomQueryService>();
builder.Services.AddSingleton<IEntrypointRepository, InMemoryEntrypointRepository>();
builder.Services.AddSingleton<IOrchestratorRepository, InMemoryOrchestratorRepository>();
builder.Services.AddSingleton<IOrchestratorControlRepository, InMemoryOrchestratorControlRepository>();
builder.Services.AddSingleton<IOrchestratorControlService>(sp =>
new OrchestratorControlService(
sp.GetRequiredService<IOrchestratorControlRepository>(),
SbomMetrics.Meter));
builder.Services.AddSingleton<IWatermarkService, InMemoryWatermarkService>();
builder.Services.AddSingleton<IProjectionRepository>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
var env = sp.GetRequiredService<IHostEnvironment>();
var configured = config.GetValue<string>("SbomService:ProjectionsPath");
if (!string.IsNullOrWhiteSpace(configured))
{
return new FileProjectionRepository(configured!);
}
var candidateRoots = new[]
{
env.ContentRootPath,
Path.GetFullPath(Path.Combine(env.ContentRootPath, "..")),
Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "..")),
Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "..", ".."))
};
foreach (var root in candidateRoots)
{
var candidate = Path.Combine(root, "docs", "modules", "sbomservice", "fixtures", "lnm-v1", "projections.json");
if (File.Exists(candidate))
{
return new FileProjectionRepository(candidate);
}
}
return new FileProjectionRepository(string.Empty);
});
static string? FindFixture(IHostEnvironment env, string fileName)
{
var candidateRoots = new[]
{
env.ContentRootPath,
Path.GetFullPath(Path.Combine(env.ContentRootPath, "..")),
Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "..")),
Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "..", ".."))
};
foreach (var root in candidateRoots)
{
var candidate = Path.Combine(root, "docs", "modules", "sbomservice", "fixtures", "lnm-v1", fileName);
if (File.Exists(candidate))
{
return candidate;
}
}
return null;
}
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.Use(async (context, next) =>
{
try
{
await next();
}
catch (Exception ex)
{
Console.WriteLine($"[dev-exception] {ex}");
throw;
}
});
app.UseDeveloperExceptionPage();
}
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
app.MapGet("/readyz", () => Results.Ok(new { status = "warming" }));
app.MapGet("/entrypoints", async Task<IResult> (
[FromServices] IEntrypointRepository repo,
[FromQuery] string? tenant,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant is required" });
}
var tenantId = tenant.Trim();
using var activity = SbomTracing.Source.StartActivity("entrypoints.list", ActivityKind.Server);
activity?.SetTag("tenant", tenantId);
var items = await repo.ListAsync(tenantId, cancellationToken);
return Results.Ok(new EntrypointListResponse(tenantId, items));
});
app.MapPost("/entrypoints", async Task<IResult> (
[FromServices] IEntrypointRepository repo,
[FromBody] EntrypointUpsertRequest request,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(request.Tenant))
{
return Results.BadRequest(new { error = "tenant is required" });
}
if (string.IsNullOrWhiteSpace(request.Artifact) || string.IsNullOrWhiteSpace(request.Service) || string.IsNullOrWhiteSpace(request.Path))
{
return Results.BadRequest(new { error = "artifact, service, and path are required" });
}
var entrypoint = new Entrypoint(
request.Artifact.Trim(),
request.Service.Trim(),
request.Path.Trim(),
string.IsNullOrWhiteSpace(request.Scope) ? "runtime" : request.Scope.Trim(),
request.RuntimeFlag);
var tenantId = request.Tenant.Trim();
using var activity = SbomTracing.Source.StartActivity("entrypoints.upsert", ActivityKind.Server);
activity?.SetTag("tenant", tenantId);
activity?.SetTag("artifact", entrypoint.Artifact);
activity?.SetTag("service", entrypoint.Service);
await repo.UpsertAsync(tenantId, entrypoint, cancellationToken);
var items = await repo.ListAsync(tenantId, cancellationToken);
return Results.Ok(new EntrypointListResponse(tenantId, items));
});
app.MapGet("/console/sboms", async Task<IResult> (
[FromServices] ISbomQueryService service,
[FromQuery] string? artifact,
[FromQuery] string? license,
[FromQuery] string? scope,
[FromQuery(Name = "assetTag")] string? assetTag,
[FromQuery] string? cursor,
[FromQuery] int? limit,
CancellationToken cancellationToken) =>
{
if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200))
{
return Results.BadRequest(new { error = "limit must be between 1 and 200" });
}
if (cursor is { Length: > 0 } && !int.TryParse(cursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
{
return Results.BadRequest(new { error = "cursor must be an integer offset" });
}
var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture);
var pageSize = limit ?? 50;
using var activity = SbomTracing.Source.StartActivity("console.sboms", ActivityKind.Server);
activity?.SetTag("artifact", artifact);
var start = Stopwatch.GetTimestamp();
var result = await service.GetConsoleCatalogAsync(
new SbomCatalogQuery(artifact?.Trim(), license?.Trim(), scope?.Trim(), assetTag?.Trim(), pageSize, offset),
cancellationToken);
var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new TagList
{
{ "scope", scope ?? string.Empty },
{ "env", string.Empty }
});
SbomMetrics.PathsQueryTotal.Add(1, new TagList
{
{ "cache_hit", result.CacheHit },
{ "scope", scope ?? string.Empty }
});
return Results.Ok(result.Result);
});
app.MapGet("/components/lookup", async Task<IResult> (
[FromServices] ISbomQueryService service,
[FromQuery] string? purl,
[FromQuery] string? artifact,
[FromQuery] string? cursor,
[FromQuery] int? limit,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(purl))
{
return Results.BadRequest(new { error = "purl is required" });
}
if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200))
{
return Results.BadRequest(new { error = "limit must be between 1 and 200" });
}
if (cursor is { Length: > 0 } && !int.TryParse(cursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
{
return Results.BadRequest(new { error = "cursor must be an integer offset" });
}
var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture);
var pageSize = limit ?? 50;
using var activity = SbomTracing.Source.StartActivity("components.lookup", ActivityKind.Server);
activity?.SetTag("purl", purl);
activity?.SetTag("artifact", artifact);
var start = Stopwatch.GetTimestamp();
var result = await service.GetComponentLookupAsync(
new ComponentLookupQuery(purl.Trim(), artifact?.Trim(), pageSize, offset),
cancellationToken);
var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new TagList
{
{ "scope", string.Empty },
{ "env", string.Empty }
});
SbomMetrics.PathsQueryTotal.Add(1, new TagList
{
{ "cache_hit", result.CacheHit },
{ "scope", string.Empty }
});
return Results.Ok(result.Result);
});
app.MapGet("/sbom/paths", async Task<IResult> (
[FromServices] IServiceProvider services,
[FromQuery] string? purl,
[FromQuery] string? artifact,
[FromQuery] string? scope,
[FromQuery(Name = "env")] string? environment,
[FromQuery] string? cursor,
[FromQuery] int? limit,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(purl))
{
return Results.BadRequest(new { error = "purl is required" });
}
if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200))
{
return Results.BadRequest(new { error = "limit must be between 1 and 200" });
}
if (cursor is { Length: > 0 } && !int.TryParse(cursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
{
return Results.BadRequest(new { error = "cursor must be an integer offset" });
}
var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture);
var pageSize = limit ?? 50;
var service = services.GetRequiredService<ISbomQueryService>();
var start = Stopwatch.GetTimestamp();
var result = await service.GetPathsAsync(
new SbomPathQuery(purl.Trim(), artifact?.Trim(), scope?.Trim(), environment?.Trim(), pageSize, offset),
cancellationToken);
var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new TagList
{
{ "scope", scope ?? string.Empty },
{ "env", environment ?? string.Empty }
});
SbomMetrics.PathsQueryTotal.Add(1, new TagList
{
{ "cache_hit", result.CacheHit },
{ "scope", scope ?? string.Empty }
});
return Results.Ok(result.Result);
});
app.MapGet("/sbom/versions", async Task<IResult> (
[FromServices] ISbomQueryService service,
[FromQuery] string? artifact,
[FromQuery] string? cursor,
[FromQuery] int? limit,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(artifact))
{
return Results.BadRequest(new { error = "artifact is required" });
}
if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200))
{
return Results.BadRequest(new { error = "limit must be between 1 and 200" });
}
if (cursor is { Length: > 0 } && !int.TryParse(cursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
{
return Results.BadRequest(new { error = "cursor must be an integer offset" });
}
var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture);
var pageSize = limit ?? 50;
var start = Stopwatch.GetTimestamp();
var result = await service.GetTimelineAsync(
new SbomTimelineQuery(artifact.Trim(), pageSize, offset),
cancellationToken);
var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
SbomMetrics.TimelineLatencySeconds.Record(elapsedSeconds, new TagList { { "artifact", artifact } });
SbomMetrics.TimelineQueryTotal.Add(1, new TagList { { "artifact", artifact }, { "cache_hit", result.CacheHit } });
return Results.Ok(result.Result);
});
app.MapGet("/sboms/{snapshotId}/projection", async Task<IResult> (
[FromServices] ISbomQueryService service,
[FromRoute] string? snapshotId,
[FromQuery(Name = "tenant")] string? tenantId,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(snapshotId))
{
return Results.BadRequest(new { error = "snapshotId is required" });
}
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "tenant is required" });
}
var start = Stopwatch.GetTimestamp();
var projection = await service.GetProjectionAsync(snapshotId.Trim(), tenantId.Trim(), cancellationToken);
if (projection is null)
{
return Results.NotFound(new { error = "projection not found" });
}
using var activity = SbomTracing.Source.StartActivity("sbom.projection", ActivityKind.Server);
activity?.SetTag("tenant", projection.TenantId);
activity?.SetTag("snapshotId", projection.SnapshotId);
activity?.SetTag("schema", projection.SchemaVersion);
var payload = new
{
snapshotId = projection.SnapshotId,
tenantId = projection.TenantId,
schemaVersion = projection.SchemaVersion,
hash = projection.ProjectionHash,
projection = projection.Projection
};
var json = JsonSerializer.Serialize(payload);
var sizeBytes = System.Text.Encoding.UTF8.GetByteCount(json);
SbomMetrics.ProjectionSizeBytes.Record(sizeBytes, new TagList { { "tenant", projection.TenantId } });
SbomMetrics.ProjectionLatencySeconds.Record(Stopwatch.GetElapsedTime(start).TotalSeconds,
new TagList { { "tenant", projection.TenantId } });
SbomMetrics.ProjectionQueryTotal.Add(1, new TagList { { "tenant", projection.TenantId } });
app.Logger.LogInformation("projection_returned tenant={Tenant} snapshot={Snapshot} size={SizeBytes}", projection.TenantId, projection.SnapshotId, sizeBytes);
return Results.Ok(payload);
});
app.MapGet("/internal/sbom/events", async Task<IResult> (
[FromServices] ISbomEventStore store,
CancellationToken cancellationToken) =>
{
using var activity = SbomTracing.Source.StartActivity("events.list", ActivityKind.Server);
var events = await store.ListAsync(cancellationToken);
SbomMetrics.EventBacklogSize.Record(events.Count);
if (events.Count > 100)
{
app.Logger.LogWarning("sbom event backlog high: {Count}", events.Count);
}
return Results.Ok(events);
});
app.MapGet("/internal/sbom/asset-events", async Task<IResult> (
[FromServices] ISbomEventStore store,
CancellationToken cancellationToken) =>
{
using var activity = SbomTracing.Source.StartActivity("asset-events.list", ActivityKind.Server);
var events = await store.ListAssetsAsync(cancellationToken);
SbomMetrics.EventBacklogSize.Record(events.Count);
if (events.Count > 100)
{
app.Logger.LogWarning("sbom asset event backlog high: {Count}", events.Count);
}
return Results.Ok(events);
});
app.MapPost("/internal/sbom/events/backfill", async Task<IResult> (
[FromServices] IProjectionRepository repository,
[FromServices] ISbomEventPublisher publisher,
[FromServices] IClock clock,
CancellationToken cancellationToken) =>
{
var projections = await repository.ListAsync(cancellationToken);
var published = 0;
foreach (var projection in projections)
{
var evt = new SbomVersionCreatedEvent(
projection.SnapshotId,
projection.TenantId,
projection.ProjectionHash,
projection.SchemaVersion,
clock.UtcNow);
if (await publisher.PublishVersionCreatedAsync(evt, cancellationToken))
{
published++;
}
}
SbomMetrics.EventBacklogSize.Record(published);
if (published > 0)
{
app.Logger.LogInformation("sbom events backfilled={Count}", published);
}
return Results.Ok(new { published });
});
app.MapGet("/internal/sbom/inventory", async Task<IResult> (
[FromServices] ISbomEventStore store,
CancellationToken cancellationToken) =>
{
using var activity = SbomTracing.Source.StartActivity("inventory.list", ActivityKind.Server);
var items = await store.ListInventoryAsync(cancellationToken);
return Results.Ok(items);
});
app.MapPost("/internal/sbom/inventory/backfill", async Task<IResult> (
[FromServices] ISbomQueryService service,
[FromServices] ISbomEventStore store,
CancellationToken cancellationToken) =>
{
// clear existing inventory and replay by listing projections
await store.ClearInventoryAsync(cancellationToken);
var projections = new[] { ("snap-001", "tenant-a") };
var published = 0;
foreach (var (snapshot, tenant) in projections)
{
await service.GetProjectionAsync(snapshot, tenant, cancellationToken);
published++;
}
return Results.Ok(new { published });
});
app.MapGet("/internal/sbom/resolver-feed", async Task<IResult> (
[FromServices] ISbomEventStore store,
CancellationToken cancellationToken) =>
{
var feed = await store.ListResolverAsync(cancellationToken);
return Results.Ok(feed);
});
app.MapPost("/internal/sbom/resolver-feed/backfill", async Task<IResult> (
[FromServices] ISbomEventStore store,
[FromServices] ISbomQueryService service,
CancellationToken cancellationToken) =>
{
await store.ClearResolverAsync(cancellationToken);
var projections = new[] { ("snap-001", "tenant-a") };
foreach (var (snapshot, tenant) in projections)
{
await service.GetProjectionAsync(snapshot, tenant, cancellationToken);
}
var feed = await store.ListResolverAsync(cancellationToken);
return Results.Ok(new { published = feed.Count });
});
app.MapGet("/internal/sbom/resolver-feed/export", async Task<IResult> (
[FromServices] ISbomEventStore store,
CancellationToken cancellationToken) =>
{
var feed = await store.ListResolverAsync(cancellationToken);
var lines = feed.Select(candidate => JsonSerializer.Serialize(candidate));
var ndjson = string.Join('\n', lines);
return Results.Text(ndjson, "application/x-ndjson");
});
app.MapGet("/internal/orchestrator/sources", async Task<IResult> (
[FromQuery] string? tenant,
[FromServices] IOrchestratorRepository repository,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant required" });
}
var sources = await repository.ListAsync(tenant.Trim(), cancellationToken);
return Results.Ok(new { tenant = tenant.Trim(), items = sources });
});
app.MapPost("/internal/orchestrator/sources", async Task<IResult> (
RegisterOrchestratorSourceRequest request,
[FromServices] IOrchestratorRepository repository,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(request.TenantId))
{
return Results.BadRequest(new { error = "tenant required" });
}
if (string.IsNullOrWhiteSpace(request.ArtifactDigest))
{
return Results.BadRequest(new { error = "artifactDigest required" });
}
if (string.IsNullOrWhiteSpace(request.SourceType))
{
return Results.BadRequest(new { error = "sourceType required" });
}
var source = await repository.RegisterAsync(request, cancellationToken);
return Results.Ok(source);
});
app.MapGet("/internal/orchestrator/control", async Task<IResult> (
[FromQuery] string? tenant,
[FromServices] IOrchestratorControlService service,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant required" });
}
var state = await service.GetAsync(tenant.Trim(), cancellationToken);
return Results.Ok(state);
});
app.MapPost("/internal/orchestrator/control", async Task<IResult> (
OrchestratorControlRequest request,
[FromServices] IOrchestratorControlService service,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(request.TenantId))
{
return Results.BadRequest(new { error = "tenant required" });
}
var updated = await service.UpdateAsync(request, cancellationToken);
return Results.Ok(updated);
});
app.MapGet("/internal/orchestrator/watermarks", async Task<IResult> (
[FromQuery] string? tenant,
[FromServices] IWatermarkService service,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant required" });
}
var state = await service.GetAsync(tenant.Trim(), cancellationToken);
return Results.Ok(state);
});
app.MapPost("/internal/orchestrator/watermarks", async Task<IResult> (
[FromQuery] string? tenant,
[FromQuery] string? watermark,
[FromServices] IWatermarkService service,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant required" });
}
var updated = await service.SetAsync(tenant.Trim(), watermark ?? string.Empty, cancellationToken);
return Results.Ok(updated);
});
app.Run();
public partial class Program;