Add unit tests and implementations for MongoDB index models and OpenAPI metadata

- Implemented `MongoIndexModelTests` to verify index models for various stores.
- Created `OpenApiMetadataFactory` with methods to generate OpenAPI metadata.
- Added tests for `OpenApiMetadataFactory` to ensure expected defaults and URL overrides.
- Introduced `ObserverSurfaceSecrets` and `WebhookSurfaceSecrets` for managing secrets.
- Developed `RuntimeSurfaceFsClient` and `WebhookSurfaceFsClient` for manifest retrieval.
- Added dependency injection tests for `SurfaceEnvironmentRegistration` in both Observer and Webhook contexts.
- Implemented tests for secret resolution in `ObserverSurfaceSecretsTests` and `WebhookSurfaceSecretsTests`.
- Created `EnsureLinkNotMergeCollectionsMigrationTests` to validate MongoDB migration logic.
- Added project files for MongoDB tests and NuGet package mirroring.
This commit is contained in:
master
2025-11-17 21:21:56 +02:00
parent d3128aec24
commit 9075bad2d9
146 changed files with 152183 additions and 82 deletions

View File

@@ -0,0 +1,77 @@
using System.Text.Json;
using Xunit;
namespace StellaOps.Notifier.Tests;
public sealed class AttestationTemplateCoverageTests
{
private static readonly string RepoRoot = LocateRepoRoot();
[Fact]
public void Attestation_templates_cover_required_channels()
{
var directory = Path.Combine(RepoRoot, "offline", "notifier", "templates", "attestation");
Assert.True(Directory.Exists(directory), $"Expected template directory at {directory}");
var templates = Directory
.GetFiles(directory, "*.template.json")
.Select(path => new
{
Path = path,
Document = JsonDocument.Parse(File.ReadAllText(path)).RootElement
})
.ToList();
var required = new Dictionary<string, string[]>
{
["tmpl-attest-verify-fail"] = new[] { "slack", "email", "webhook" },
["tmpl-attest-expiry-warning"] = new[] { "email", "slack" },
["tmpl-attest-key-rotation"] = new[] { "email", "webhook" },
["tmpl-attest-transparency-anomaly"] = new[] { "slack", "webhook" }
};
foreach (var pair in required)
{
var matches = templates.Where(t => t.Document.GetProperty("key").GetString() == pair.Key);
var channels = matches
.Select(t => t.Document.GetProperty("channelType").GetString() ?? string.Empty)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var missing = pair.Value.Where(requiredChannel => !channels.Contains(requiredChannel)).ToArray();
Assert.True(missing.Length == 0, $"{pair.Key} missing channels: {string.Join(", ", missing)}");
}
}
[Fact]
public void Attestation_templates_include_schema_and_locale_metadata()
{
var directory = Path.Combine(RepoRoot, "offline", "notifier", "templates", "attestation");
Assert.True(Directory.Exists(directory), $"Expected template directory at {directory}");
foreach (var path in Directory.GetFiles(directory, "*.template.json"))
{
var document = JsonDocument.Parse(File.ReadAllText(path)).RootElement;
Assert.True(document.TryGetProperty("schemaVersion", out var schemaVersion) && !string.IsNullOrWhiteSpace(schemaVersion.GetString()), $"schemaVersion missing for {Path.GetFileName(path)}");
Assert.True(document.TryGetProperty("locale", out var locale) && !string.IsNullOrWhiteSpace(locale.GetString()), $"locale missing for {Path.GetFileName(path)}");
Assert.True(document.TryGetProperty("key", out var key) && !string.IsNullOrWhiteSpace(key.GetString()), $"key missing for {Path.GetFileName(path)}");
}
}
private static string LocateRepoRoot()
{
var directory = AppContext.BaseDirectory;
while (directory != null)
{
var candidate = Path.Combine(directory, "offline", "notifier", "templates", "attestation");
if (Directory.Exists(candidate))
{
return directory;
}
directory = Directory.GetParent(directory)?.FullName;
}
throw new InvalidOperationException("Unable to locate repository root containing offline/notifier/templates/attestation.");
}
}

View File

@@ -0,0 +1,66 @@
using System.Text.Json;
using Xunit;
namespace StellaOps.Notifier.Tests;
public sealed class DeprecationTemplateTests
{
[Fact]
public void Deprecation_templates_cover_slack_and_email()
{
var directory = LocateOfflineDeprecationDir();
Assert.True(Directory.Exists(directory), $"Expected template directory at {directory}");
var templates = Directory
.GetFiles(directory, "*.template.json")
.Select(path => new
{
Path = path,
Document = JsonDocument.Parse(File.ReadAllText(path)).RootElement
})
.ToList();
var channels = templates
.Where(t => t.Document.GetProperty("key").GetString() == "tmpl-api-deprecation")
.Select(t => t.Document.GetProperty("channelType").GetString() ?? string.Empty)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
Assert.Contains("slack", channels);
Assert.Contains("email", channels);
}
[Fact]
public void Deprecation_templates_require_core_metadata()
{
var directory = LocateOfflineDeprecationDir();
Assert.True(Directory.Exists(directory), $"Expected template directory at {directory}");
foreach (var path in Directory.GetFiles(directory, "*.template.json"))
{
var document = JsonDocument.Parse(File.ReadAllText(path)).RootElement;
Assert.True(document.TryGetProperty("metadata", out var meta), $"metadata missing for {Path.GetFileName(path)}");
// Ensure documented metadata keys are present for offline baseline.
Assert.True(meta.TryGetProperty("version", out _), $"metadata.version missing for {Path.GetFileName(path)}");
Assert.True(meta.TryGetProperty("author", out _), $"metadata.author missing for {Path.GetFileName(path)}");
}
}
private static string LocateOfflineDeprecationDir()
{
var directory = AppContext.BaseDirectory;
while (directory != null)
{
var candidate = Path.Combine(directory, "offline", "notifier", "templates", "deprecation");
if (Directory.Exists(candidate))
{
return candidate;
}
directory = Directory.GetParent(directory)?.FullName;
}
throw new InvalidOperationException("Unable to locate offline/notifier/templates/deprecation directory.");
}
}

View File

@@ -0,0 +1,87 @@
using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;
using StellaOps.Notifier.WebService;
using Xunit;
namespace StellaOps.Notifier.Tests;
public sealed class OpenApiEndpointTests : IClassFixture<WebApplicationFactory<WebServiceAssemblyMarker>>
{
private readonly HttpClient _client;
private readonly InMemoryPackApprovalRepository _packRepo = new();
private readonly InMemoryLockRepository _lockRepo = new();
private readonly InMemoryAuditRepository _auditRepo = new();
public OpenApiEndpointTests(WebApplicationFactory<WebServiceAssemblyMarker> factory)
{
_client = factory
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.AddSingleton<INotifyPackApprovalRepository>(_packRepo);
services.AddSingleton<INotifyLockRepository>(_lockRepo);
services.AddSingleton<INotifyAuditRepository>(_auditRepo);
});
})
.CreateClient();
}
[Fact]
public async Task OpenApi_endpoint_serves_yaml_with_scope_header()
{
var response = await _client.GetAsync("/.well-known/openapi", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("application/yaml", response.Content.Headers.ContentType?.MediaType);
Assert.True(response.Headers.TryGetValues("X-OpenAPI-Scope", out var values) &&
values.Contains("notify"));
Assert.True(response.Headers.ETag is not null && response.Headers.ETag.Tag.Length > 2);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.Contains("openapi: 3.1.0", body);
Assert.Contains("/api/v1/notify/quiet-hours", body);
Assert.Contains("/api/v1/notify/incidents", body);
}
[Fact]
public async Task Deprecation_headers_emitted_for_api_surface()
{
var response = await _client.GetAsync("/api/v1/notify/rules", TestContext.Current.CancellationToken);
Assert.True(response.Headers.TryGetValues("Deprecation", out var depValues) &&
depValues.Contains("true"));
Assert.True(response.Headers.TryGetValues("Sunset", out var sunsetValues) &&
sunsetValues.Any());
Assert.True(response.Headers.TryGetValues("Link", out var linkValues) &&
linkValues.Any(v => v.Contains("rel=\"deprecation\"")));
}
[Fact]
public async Task PackApprovals_endpoint_validates_missing_headers()
{
var content = new StringContent("""{"eventId":"00000000-0000-0000-0000-000000000001","issuedAt":"2025-11-17T16:00:00Z","kind":"pack.approval.granted","packId":"offline-kit","decision":"approved","actor":"task-runner"}""", Encoding.UTF8, "application/json");
var response = await _client.PostAsync("/api/v1/notify/pack-approvals", content, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PackApprovals_endpoint_accepts_happy_path_and_echoes_resume_token()
{
var content = new StringContent("""{"eventId":"00000000-0000-0000-0000-000000000002","issuedAt":"2025-11-17T16:00:00Z","kind":"pack.approval.granted","packId":"offline-kit","decision":"approved","actor":"task-runner","resumeToken":"rt-ok"}""", Encoding.UTF8, "application/json");
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/notify/pack-approvals")
{
Content = content
};
request.Headers.Add("X-StellaOps-Tenant", "tenant-a");
request.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString());
var response = await _client.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
Assert.True(response.Headers.TryGetValues("X-Resume-After", out var resumeValues) &&
resumeValues.Contains("rt-ok"));
Assert.True(_packRepo.Exists("tenant-a", Guid.Parse("00000000-0000-0000-0000-000000000002"), "offline-kit"));
}
}

View File

@@ -0,0 +1,30 @@
using StellaOps.Notify.Storage.Mongo.Documents;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notifier.Tests.Support;
internal sealed class InMemoryAuditRepository : INotifyAuditRepository
{
private readonly List<NotifyAuditEntryDocument> _entries = new();
public Task AppendAsync(NotifyAuditEntryDocument entry, CancellationToken cancellationToken = default)
{
_entries.Add(entry);
return Task.CompletedTask;
}
public Task<IReadOnlyList<NotifyAuditEntryDocument>> QueryAsync(string tenantId, DateTimeOffset? since, int? limit, CancellationToken cancellationToken = default)
{
var items = _entries
.Where(e => e.TenantId == tenantId && (!since.HasValue || e.Timestamp >= since.Value))
.OrderByDescending(e => e.Timestamp)
.ToList();
if (limit is > 0)
{
items = items.Take(limit.Value).ToList();
}
return Task.FromResult<IReadOnlyList<NotifyAuditEntryDocument>>(items);
}
}

View File

@@ -0,0 +1,18 @@
using StellaOps.Notify.Storage.Mongo.Documents;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notifier.Tests.Support;
internal sealed class InMemoryPackApprovalRepository : INotifyPackApprovalRepository
{
private readonly Dictionary<(string TenantId, Guid EventId, string PackId), PackApprovalDocument> _records = new();
public Task UpsertAsync(PackApprovalDocument document, CancellationToken cancellationToken = default)
{
_records[(document.TenantId, document.EventId, document.PackId)] = document;
return Task.CompletedTask;
}
public bool Exists(string tenantId, Guid eventId, string packId)
=> _records.ContainsKey((tenantId, eventId, packId));
}

View File

@@ -0,0 +1,45 @@
using System.Text.Json.Serialization;
namespace StellaOps.Notifier.WebService.Contracts;
public sealed class PackApprovalRequest
{
[JsonPropertyName("eventId")]
public Guid EventId { get; init; }
[JsonPropertyName("issuedAt")]
public DateTimeOffset IssuedAt { get; init; }
[JsonPropertyName("kind")]
public string Kind { get; init; } = string.Empty;
[JsonPropertyName("packId")]
public string PackId { get; init; } = string.Empty;
[JsonPropertyName("policy")]
public PackApprovalPolicy? Policy { get; init; }
[JsonPropertyName("decision")]
public string Decision { get; init; } = string.Empty;
[JsonPropertyName("actor")]
public string Actor { get; init; } = string.Empty;
[JsonPropertyName("resumeToken")]
public string? ResumeToken { get; init; }
[JsonPropertyName("summary")]
public string? Summary { get; init; }
[JsonPropertyName("labels")]
public Dictionary<string, string>? Labels { get; init; }
}
public sealed class PackApprovalPolicy
{
[JsonPropertyName("id")]
public string? Id { get; init; }
[JsonPropertyName("version")]
public string? Version { get; init; }
}

View File

@@ -1,24 +1,141 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using StellaOps.Notify.Storage.Mongo;
using StellaOps.Notifier.WebService.Setup;
var builder = WebApplication.CreateBuilder(args);
builder.Configuration
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
.AddEnvironmentVariables(prefix: "NOTIFIER_");
var mongoSection = builder.Configuration.GetSection("notifier:storage:mongo");
builder.Services.AddNotifyMongoStorage(mongoSection);
builder.Services.AddHealthChecks();
builder.Services.AddHostedService<MongoInitializationHostedService>();
var app = builder.Build();
app.MapHealthChecks("/healthz");
app.Run();
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using StellaOps.Notifier.WebService.Contracts;
using StellaOps.Notifier.WebService.Setup;
using StellaOps.Notify.Storage.Mongo;
using StellaOps.Notify.Storage.Mongo.Documents;
using StellaOps.Notify.Storage.Mongo.Repositories;
var builder = WebApplication.CreateBuilder(args);
builder.Configuration
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
.AddEnvironmentVariables(prefix: "NOTIFIER_");
var mongoSection = builder.Configuration.GetSection("notifier:storage:mongo");
builder.Services.AddNotifyMongoStorage(mongoSection);
builder.Services.AddSingleton<OpenApiDocumentCache>();
builder.Services.AddHealthChecks();
builder.Services.AddHostedService<MongoInitializationHostedService>();
var app = builder.Build();
app.MapHealthChecks("/healthz");
// Deprecation headers for retiring v1 APIs (RFC 8594 / IETF Sunset)
app.Use(async (context, next) =>
{
if (context.Request.Path.StartsWithSegments("/api/v1", StringComparison.OrdinalIgnoreCase))
{
context.Response.Headers["Deprecation"] = "true";
context.Response.Headers["Sunset"] = "Tue, 31 Mar 2026 00:00:00 GMT";
context.Response.Headers["Link"] =
"<https://docs.stellaops.example.com/notify/deprecations>; rel=\"deprecation\"; type=\"text/html\"";
}
await next().ConfigureAwait(false);
});
app.MapPost("/api/v1/notify/pack-approvals", async (
HttpContext context,
PackApprovalRequest request,
INotifyLockRepository locks,
INotifyPackApprovalRepository packApprovals,
INotifyAuditRepository audit,
TimeProvider timeProvider) =>
{
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var idempotencyKey = context.Request.Headers["Idempotency-Key"].ToString();
if (string.IsNullOrWhiteSpace(idempotencyKey))
{
return Results.BadRequest(Error("idempotency_key_missing", "Idempotency-Key header is required.", context));
}
if (request.EventId == Guid.Empty || string.IsNullOrWhiteSpace(request.PackId) ||
string.IsNullOrWhiteSpace(request.Kind) || string.IsNullOrWhiteSpace(request.Decision) ||
string.IsNullOrWhiteSpace(request.Actor))
{
return Results.BadRequest(Error("invalid_request", "eventId, packId, kind, decision, actor are required.", context));
}
var lockKey = $"pack-approvals|{tenantId}|{idempotencyKey}";
var ttl = TimeSpan.FromMinutes(15);
var reserved = await locks.TryAcquireAsync(tenantId, lockKey, "pack-approvals", ttl, context.RequestAborted)
.ConfigureAwait(false);
if (!reserved)
{
return Results.StatusCode(StatusCodes.Status200OK);
}
var document = new PackApprovalDocument
{
TenantId = tenantId,
EventId = request.EventId,
PackId = request.PackId,
Kind = request.Kind,
Decision = request.Decision,
Actor = request.Actor,
IssuedAt = request.IssuedAt,
PolicyId = request.Policy?.Id,
PolicyVersion = request.Policy?.Version,
ResumeToken = request.ResumeToken,
Summary = request.Summary,
Labels = request.Labels,
CreatedAt = timeProvider.GetUtcNow()
};
await packApprovals.UpsertAsync(document, context.RequestAborted).ConfigureAwait(false);
var auditEntry = new NotifyAuditEntryDocument
{
TenantId = tenantId,
Actor = request.Actor,
Action = "pack.approval.ingested",
EntityId = request.PackId,
EntityType = "pack-approval",
Timestamp = timeProvider.GetUtcNow(),
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(JsonSerializer.Serialize(request))
};
await audit.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(request.ResumeToken))
{
context.Response.Headers["X-Resume-After"] = request.ResumeToken;
}
return Results.Accepted();
});
app.MapGet("/.well-known/openapi", (HttpContext context, OpenApiDocumentCache cache) =>
{
context.Response.Headers.CacheControl = "public, max-age=300";
context.Response.Headers["X-OpenAPI-Scope"] = "notify";
context.Response.Headers.ETag = $"\"{cache.Sha256}\"";
return Results.Content(cache.Document, "application/yaml");
});
app.Run();
public partial class Program;
static object Error(string code, string message, HttpContext context) => new
{
error = new
{
code,
message,
traceId = context.TraceIdentifier
}
};

View File

@@ -0,0 +1,28 @@
using System.Text;
namespace StellaOps.Notifier.WebService.Setup;
public sealed class OpenApiDocumentCache
{
private readonly string _document;
private readonly string _hash;
public OpenApiDocumentCache(IHostEnvironment environment)
{
var path = Path.Combine(environment.ContentRootPath, "openapi", "notify-openapi.yaml");
if (!File.Exists(path))
{
throw new FileNotFoundException("OpenAPI document not found.", path);
}
_document = File.ReadAllText(path, Encoding.UTF8);
using var sha = System.Security.Cryptography.SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(_document);
_hash = Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant();
}
public string Document => _document;
public string Sha256 => _hash;
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.Notifier.WebService;
/// <summary>
/// Marker type used for testing/hosting the web application.
/// </summary>
public sealed class WebServiceAssemblyMarker;

View File

@@ -0,0 +1,501 @@
# OpenAPI 3.1 specification for StellaOps Notifier WebService (draft)
openapi: 3.1.0
info:
title: StellaOps Notifier API
version: 0.6.0-draft
description: |
Contract for Notifications Studio (Notifier) covering rules, templates, incidents,
and quiet hours. Uses the platform error envelope and tenant header `X-StellaOps-Tenant`.
servers:
- url: https://api.stellaops.example.com
description: Production
- url: https://api.dev.stellaops.example.com
description: Development
security:
- oauth2: [notify.viewer]
- oauth2: [notify.operator]
- oauth2: [notify.admin]
paths:
/api/v1/notify/rules:
get:
summary: List notification rules
tags: [Rules]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/PageSize'
- $ref: '#/components/parameters/PageToken'
responses:
'200':
description: Paginated rule list
content:
application/json:
schema:
type: object
properties:
items:
type: array
items: { $ref: '#/components/schemas/NotifyRule' }
nextPageToken:
type: string
examples:
default:
value:
items:
- ruleId: rule-critical
tenantId: tenant-dev
name: Critical scanner verdicts
enabled: true
match:
eventKinds: [scanner.report.ready]
minSeverity: critical
actions:
- actionId: act-slack-critical
channel: chn-slack-soc
template: tmpl-critical
digest: instant
nextPageToken: null
default:
$ref: '#/components/responses/Error'
post:
summary: Create a notification rule
tags: [Rules]
parameters:
- $ref: '#/components/parameters/Tenant'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyRule' }
examples:
create-rule:
value:
ruleId: rule-attest-fail
tenantId: tenant-dev
name: Attestation failures → SOC
enabled: true
match:
eventKinds: [attestor.verification.failed]
actions:
- actionId: act-soc
channel: chn-webhook-soc
template: tmpl-attest-verify-fail
responses:
'201':
description: Rule created
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyRule' }
default:
$ref: '#/components/responses/Error'
/api/v1/notify/rules/{ruleId}:
get:
summary: Fetch a rule
tags: [Rules]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/RuleId'
responses:
'200':
description: Rule
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyRule' }
default:
$ref: '#/components/responses/Error'
patch:
summary: Update a rule (partial)
tags: [Rules]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/RuleId'
requestBody:
required: true
content:
application/json:
schema:
type: object
description: JSON Merge Patch
responses:
'200':
description: Updated rule
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyRule' }
default:
$ref: '#/components/responses/Error'
/api/v1/notify/templates:
get:
summary: List templates
tags: [Templates]
parameters:
- $ref: '#/components/parameters/Tenant'
- name: key
in: query
description: Filter by template key
schema: { type: string }
responses:
'200':
description: Templates
content:
application/json:
schema:
type: array
items: { $ref: '#/components/schemas/NotifyTemplate' }
default:
$ref: '#/components/responses/Error'
post:
summary: Create a template
tags: [Templates]
parameters:
- $ref: '#/components/parameters/Tenant'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyTemplate' }
responses:
'201':
description: Template created
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyTemplate' }
default:
$ref: '#/components/responses/Error'
/api/v1/notify/templates/{templateId}:
get:
summary: Fetch a template
tags: [Templates]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/TemplateId'
responses:
'200':
description: Template
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyTemplate' }
default:
$ref: '#/components/responses/Error'
patch:
summary: Update a template (partial)
tags: [Templates]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/TemplateId'
requestBody:
required: true
content:
application/json:
schema:
type: object
description: JSON Merge Patch
responses:
'200':
description: Updated template
content:
application/json:
schema: { $ref: '#/components/schemas/NotifyTemplate' }
default:
$ref: '#/components/responses/Error'
/api/v1/notify/incidents:
get:
summary: List incidents (paged)
tags: [Incidents]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/PageSize'
- $ref: '#/components/parameters/PageToken'
responses:
'200':
description: Incident page
content:
application/json:
schema:
type: object
properties:
items:
type: array
items: { $ref: '#/components/schemas/Incident' }
nextPageToken: { type: string }
default:
$ref: '#/components/responses/Error'
post:
summary: Raise an incident (ops/toggle/override)
tags: [Incidents]
parameters:
- $ref: '#/components/parameters/Tenant'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/Incident' }
examples:
start-incident:
value:
incidentId: inc-telemetry-outage
kind: outage
severity: major
startedAt: 2025-11-17T04:02:00Z
shortDescription: "Telemetry pipeline degraded; burn-rate breach"
metadata:
source: slo-evaluator
responses:
'202':
description: Incident accepted
default:
$ref: '#/components/responses/Error'
/api/v1/notify/incidents/{incidentId}/ack:
post:
summary: Acknowledge an incident notification
tags: [Incidents]
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/IncidentId'
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
ackToken:
type: string
description: DSSE-signed acknowledgement token
responses:
'204':
description: Acknowledged
default:
$ref: '#/components/responses/Error'
/api/v1/notify/quiet-hours:
get:
summary: Get quiet-hours schedule
tags: [QuietHours]
parameters:
- $ref: '#/components/parameters/Tenant'
responses:
'200':
description: Quiet hours schedule
content:
application/json:
schema: { $ref: '#/components/schemas/QuietHours' }
examples:
current:
value:
quietHoursId: qh-default
windows:
- timezone: UTC
days: [Mon, Tue, Wed, Thu, Fri]
start: "22:00"
end: "06:00"
exemptions:
- eventKinds: [attestor.verification.failed]
reason: "Always alert for attestation failures"
default:
$ref: '#/components/responses/Error'
post:
summary: Set quiet-hours schedule
tags: [QuietHours]
parameters:
- $ref: '#/components/parameters/Tenant'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/QuietHours' }
responses:
'200':
description: Updated quiet hours
content:
application/json:
schema: { $ref: '#/components/schemas/QuietHours' }
default:
$ref: '#/components/responses/Error'
components:
securitySchemes:
oauth2:
type: oauth2
flows:
clientCredentials:
tokenUrl: https://auth.stellaops.example.com/oauth/token
scopes:
notify.viewer: Read-only Notifier access
notify.operator: Manage rules/templates/incidents within tenant
notify.admin: Tenant-scoped administration
parameters:
Tenant:
name: X-StellaOps-Tenant
in: header
required: true
description: Tenant slug
schema: { type: string }
PageSize:
name: pageSize
in: query
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
PageToken:
name: pageToken
in: query
schema: { type: string }
RuleId:
name: ruleId
in: path
required: true
schema: { type: string }
TemplateId:
name: templateId
in: path
required: true
schema: { type: string }
IncidentId:
name: incidentId
in: path
required: true
schema: { type: string }
responses:
Error:
description: Standard error envelope
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorEnvelope' }
examples:
validation:
value:
error:
code: validation_failed
message: "quietHours.windows[0].start must be HH:mm"
traceId: "f62f3c2b9c8e4c53"
schemas:
ErrorEnvelope:
type: object
required: [error]
properties:
error:
type: object
required: [code, message, traceId]
properties:
code: { type: string }
message: { type: string }
traceId: { type: string }
NotifyRule:
type: object
required: [ruleId, tenantId, name, match, actions]
properties:
ruleId: { type: string }
tenantId: { type: string }
name: { type: string }
description: { type: string }
enabled: { type: boolean, default: true }
match: { $ref: '#/components/schemas/RuleMatch' }
actions:
type: array
items: { $ref: '#/components/schemas/RuleAction' }
labels:
type: object
additionalProperties: { type: string }
metadata:
type: object
additionalProperties: { type: string }
RuleMatch:
type: object
properties:
eventKinds:
type: array
items: { type: string }
minSeverity: { type: string, enum: [info, low, medium, high, critical] }
verdicts:
type: array
items: { type: string }
labels:
type: array
items: { type: string }
kevOnly: { type: boolean }
RuleAction:
type: object
required: [actionId, channel]
properties:
actionId: { type: string }
channel: { type: string }
template: { type: string }
digest: { type: string, description: "Digest window key e.g. instant|5m|15m|1h|1d" }
throttle: { type: string, description: "ISO-8601 duration, e.g. PT5M" }
locale: { type: string }
enabled: { type: boolean, default: true }
metadata:
type: object
additionalProperties: { type: string }
NotifyTemplate:
type: object
required: [templateId, tenantId, key, channelType, locale, body, renderMode, format]
properties:
templateId: { type: string }
tenantId: { type: string }
key: { type: string }
channelType: { type: string, enum: [slack, teams, email, webhook, custom] }
locale: { type: string, description: "BCP-47, lower-case" }
renderMode: { type: string, enum: [Markdown, Html, AdaptiveCard, PlainText, Json] }
format: { type: string, enum: [slack, teams, email, webhook, json] }
description: { type: string }
body: { type: string }
metadata:
type: object
additionalProperties: { type: string }
Incident:
type: object
required: [incidentId, kind, severity, startedAt]
properties:
incidentId: { type: string }
kind: { type: string, description: "outage|degradation|security|ops-drill" }
severity: { type: string, enum: [minor, major, critical] }
startedAt: { type: string, format: date-time }
endedAt: { type: string, format: date-time }
shortDescription: { type: string }
description: { type: string }
metadata:
type: object
additionalProperties: { type: string }
QuietHours:
type: object
required: [quietHoursId, windows]
properties:
quietHoursId: { type: string }
windows:
type: array
items: { $ref: '#/components/schemas/QuietHoursWindow' }
exemptions:
type: array
description: Event kinds that bypass quiet hours
items:
type: object
properties:
eventKinds:
type: array
items: { type: string }
reason: { type: string }
QuietHoursWindow:
type: object
required: [timezone, days, start, end]
properties:
timezone: { type: string, description: "IANA TZ, e.g., UTC" }
days:
type: array
items:
type: string
enum: [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
start: { type: string, description: "HH:mm" }
end: { type: string, description: "HH:mm" }

View File

@@ -0,0 +1,15 @@
# Sprint 171 · Notifier.I
| ID | Status | Owner(s) | Notes |
| --- | --- | --- | --- |
| NOTIFY-ATTEST-74-001 | DONE (2025-11-16) | Notifications Service Guild | Attestation template suite complete; Slack expiry template added; coverage tests guard required channels. |
| NOTIFY-ATTEST-74-002 | TODO | Notifications Service Guild · KMS Guild | Wire notifications to key rotation/revocation events + transparency witness failures (depends on 74-001). |
| NOTIFY-OAS-61-001 | DONE (2025-11-17) | Notifications Service Guild · API Contracts Guild | OAS updated with rules/templates/incidents/quiet hours and standard error envelope. |
| NOTIFY-OAS-61-002 | DONE (2025-11-17) | Notifications Service Guild | `.well-known/openapi` discovery endpoint with scope metadata implemented. |
| NOTIFY-OAS-62-001 | DONE (2025-11-17) | Notifications Service Guild · SDK Generator Guild | SDK usage examples + smoke tests (depends on 61-002). |
| NOTIFY-OAS-63-001 | TODO | Notifications Service Guild · API Governance Guild | Deprecation headers + template notices for retiring APIs (depends on 62-001). |
| NOTIFY-OBS-51-001 | TODO | Notifications Service Guild · Observability Guild | Integrate SLO evaluator webhooks once schema lands. |
| NOTIFY-OBS-55-001 | TODO | Notifications Service Guild · Ops Guild | Incident mode start/stop notifications; quiet-hour overrides. |
| NOTIFY-RISK-66-001 | TODO | Notifications Service Guild · Risk Engine Guild | Trigger risk severity escalation/downgrade notifications (waiting on Policy export). |
| NOTIFY-RISK-67-001 | TODO | Notifications Service Guild · Policy Guild | Notify when risk profiles publish/deprecate/threshold-change (depends on 66-001). |
| NOTIFY-RISK-68-001 | TODO | Notifications Service Guild | Per-profile routing rules + quiet hours for risk alerts (depends on 67-001). |

View File

@@ -0,0 +1,15 @@
# Notifier OAS Discovery — ETag Guidance
The Notifier WebService exposes its OpenAPI document at `/.well-known/openapi` with headers:
- `X-OpenAPI-Scope: notify`
- `ETag: "<sha256>"` (stable per spec bytes)
- `Cache-Control: public, max-age=300`
Usage notes:
- SDK generators and CI smoke tests should re-use the `ETag` for conditional GETs (`If-None-Match`) to avoid redundant downloads.
- Mirror/Offline bundles should copy `openapi/notify-openapi.yaml` and retain the `ETag` alongside the file hash used in air-gap validation.
- When the spec changes, the SHA-256 and `ETag` change together; callers can detect breaking/non-breaking updates via the published changelog (source of truth in `docs/api/notify-openapi.yaml`).
Applies to tasks: NOTIFY-OAS-61-001/61-002/63-001.