NimBus WebApp REST API
The HTTP control plane for a NimBus installation. Used by the management SPA
that ships in NimBus.WebApp, and intended for external callers
(CI scripts, automation, partner integrations) over time.
This page is the human-readable companion to the machine-readable contract at
src/NimBus.WebApp/api-spec.yaml. The
spec is the source of truth; this page exists to orient external integrators
and to be honest about which parts of "external use" work today versus which
are still on the roadmap.
At a glance
- Title in the spec: EIP Management Web API —
api-spec.yaml:3. - Spec version:
0.0.1(pre-1.0; see Versioning + stability below). - Route prefix: every operation lives under
/api/…. - Surface size: 115 operations across 12 tags (Admin, Event, Endpoint, AccessControl, Agent, Metrics, EventType, StorageHook, Application, Message, Audit, Heartbeat).
- Auth: ASP.NET Identity cookie and/or Microsoft Entra ID (interactive browser flow + bearer-token flow). Effectively all
/api/…endpoints require an authenticated principal. Two are reachable anonymously by design:GET /api/app/stats(a liveness shape that discloses nothing to an anonymous caller — see Application) andPOST /api/storagehook/cosmos/{endpointId}(gated by a shared webhook key rather than a principal). The agent API (/api/agent/*) also accepts an agent API key inX-NimBus-Agent-Key, which admits nothing else — see Agent API keys. Beyond/api/…,/health,/aliveand/readyare anonymous. - Schema generation: NSwag reads
api-spec.yamlpre-build and emits server contracts (Controllers/ApiContract.g.cs) plus a TypeScript client (ClientApp/src/api-client/index.ts) — see Code generation.
Audience
Two distinct audiences share the same surface:
-
Operators driving the WebApp SPA. The React app under
src/NimBus.WebApp/ClientApp/calls every endpoint here, authenticated by the same cookie or bearer token the user signs in with. -
External HTTP callers. CI scripts, partner automation, internal tools reaching across service boundaries. Today external use works server-to-server with an Entra ID access token; see Calling from outside the SPA for the supported shape and What's not external-ready yet for the rough edges.
Authentication
Authentication is configured in
src/NimBus.WebApp/Startup.cs:60-189. Which
branch runs depends on configuration; the four supported shapes are:
1. Operator — Identity cookie
When NimBusIdentity:ConnectionString is configured and AzureAd:ClientId is
not (Startup.cs:110-120), the WebApp runs in Identity-only mode: users
sign in with email + password, the server issues a cookie named
NimBus.Identity with an 8-hour sliding expiry, and every /api/… request
must carry that cookie.
This is the operator path. See docs/authentication.md
for password-policy, email-confirmation, and bootstrap-admin details.
2. Operator — Entra ID (interactive)
When AzureAd:ClientId is set (Startup.cs:121-188), the WebApp uses
Microsoft Identity Web. Browser sign-in goes through OpenID Connect;
authenticated browsers carry the standard ASP.NET cookie.
3. Dual — Identity and Entra ID
Both schemes registered concurrently (Startup.cs:121-157). A policy scheme
named "Az" inspects each request and forwards to the right handler:
- Request carries
Authorization: Bearer …→ JWT bearer. - Request carries the
NimBus.Identitycookie → ASP.NET Identity scheme. - Otherwise → OpenID Connect challenge.
4. External — Entra ID bearer token
External callers use the JWT bearer arm of the same scheme. Acquire an Entra ID access token via the OAuth 2.0 client-credentials flow (or on-behalf-of for delegated automation), then attach it to every request:
GET /api/endpoint/status/count HTTP/1.1
Host: nimbus.example.com
Authorization: Bearer <access-token>
Audience and scope come from the WebApp's AzureAd registration — see
docs/authentication.md for the app-registration setup.
The bearer arm is JwtBearerDefaults.AuthenticationScheme, validated by
Microsoft.Identity.Web (Startup.cs:145, 176).
Local dev shortcut
For local development only, setting EnableLocalDevAuthentication=true
(Startup.cs:101-109) installs a "LocalDev" scheme that authenticates every
request as a fixed test user. Startup fails fast if this flag is enabled
outside the Development environment (Startup.cs:64-71). Never use it in
production.
Calling from outside the SPA
The supported external shape today is server-to-server with an Entra ID bearer
token. A minimal curl:
TOKEN=$(curl -s -X POST \
"https://login.microsoftonline.com/$TENANT/oauth2/v2.0/token" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
-d "scope=$NIMBUS_API_AUDIENCE/.default" \
-d "grant_type=client_credentials" \
| jq -r .access_token)
curl -H "Authorization: Bearer $TOKEN" \
https://nimbus.example.com/api/endpoint/status/count
A bearer-token caller needs no antiforgery token. A script that instead reuses a browser's sign-in
cookie is treated as a browser session: it must GET an /api/… route first, and then send the
NimBus.CSRF cookie's value as the X-NimBus-CSRF header on writes, together with the
antiforgery cookie that came back. Otherwise writes get 400. See
Cross-site request forgery.
If you need a TypeScript / Python / Go client, point an OpenAPI generator at
src/NimBus.WebApp/api-spec.yaml — see
Code generation.
Versioning + stability
Today, no compatibility commitment is in place. Concretely:
- The spec lists
version: 0.0.1(api-spec.yaml:5). - Routes do not carry a
/api/v1/prefix; every endpoint is/api/…unversioned. - The generated TS client injects an
api-version: 2header on every request (ClientApp/src/api-client/index.extensions.ts:45) — this is current internal plumbing, not a versioning contract you can rely on. - Individual operations may be marked
[Obsolete]in code (e.g.Controllers/ApiContract/StorageHookImplementation.csfor the renamed storage hook); these signal deprecation but not removal timelines.
Practically: expect endpoints to move and rename until a v1 cut happens.
Treat the spec as authoritative for the shape of each operation, the route
table as best-effort for naming, and pin your client against a known spec
hash rather than tracking main.
Conventions
- Format: every request and response body is JSON. The TS client
serialises with
JSON.stringify; server-side serialisation usesSystem.Text.Jsonwith camelCase property names. - Content-Type:
application/jsonon every body-bearing request. - Pagination: several endpoints use a cursor-style
continuationToken+maxItemCountpair (e.g./api/messages/search,/api/audits/search). Pass the value from the previous response back in to fetch the next page; an empty/null token means "no more pages." - Errors: currently inconsistent. Implementations return ad-hoc shapes
via
ForbidResult(),NotFoundObjectResult(), and similar (seeControllers/ApiContract/AdminImplementation.cs:33-50). There is no globalProblemDetailsmapping yet — callers should branch on HTTP status code and treat the body as best-effort context. Standardising toProblemDetailsis on the gap list. - HTTPS only: redirected by
app.UseHttpsRedirection()(Startup.cs:404).
Domain reference
One subsection per OpenAPI tag, with a representative endpoint. The full
operation list lives in api-spec.yaml;
this section is a tour, not an exhaustive enumeration.
Endpoint
Endpoint status, sessions, and per-endpoint inspection. Example:
curl -H "Authorization: Bearer $TOKEN" \
https://nimbus.example.com/api/endpoint/status/count
Returns an array of EndpointStatusCount rows (api-spec.yaml:10-25).
Event
Inspect, resubmit, skip, and re-compose messages flowing through endpoints. Example — resubmit a failed event by its id pair:
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
"https://nimbus.example.com/api/event/resubmit/evt-123/msg-456"
(Operation id post-resubmit-event-ids.)
Resubmit, skip and resubmit-with-changes act on the event's latest attempt: messageId must be the
event's lastMessageId. They return 409 and send nothing when it no longer is, because the
event was resubmitted or skipped meanwhile (from the Web UI or an MCP agent) or failed again; reload
the event and retry. They return 503 when the audit row cannot be written. These commands share
one guarded path with the operator MCP tools: the caller's Contributor role is re-read from the
store, the event is claimed at the version loaded, the audit row is written, and only then is the
command published.
EventType
Catalog of registered event types and per-endpoint event-type listings. Example:
curl -H "Authorization: Bearer $TOKEN" \
https://nimbus.example.com/api/event-types
Server-side schema-valid fake payloads
GET /api/event-types/{eventtypeid}/fake (operation id
get-eventtypes-eventtypeid-fake) reflects over the registered CLR type and
returns a randomized JSON payload that is guaranteed to deserialize as the
type and pass the type's own IEvent.TryValidate rules (the same gate the
Compose / Resubmit-with-changes submit path applies). The WebApp's
"Generate fake data" button in the Compose dialog calls this endpoint
instead of guessing values in the browser.
Request:
curl -H "Authorization: Bearer $TOKEN" \
https://nimbus.example.com/api/event-types/CustomerRegistered/fake
Response shape (200 OK):
{
"payload": "{\n \"CustomerId\": \"…\",\n \"Email\": \"alex.hansen@example.com\",\n \"FullName\": \"Alex Hansen\"\n}"
}
The payload field is a fully-formed JSON string (indented), not a
structured object — the client inserts it verbatim into the textarea. When
the type cannot be constructed (abstract, interface, or no accessible
parameterless constructor) the field is null and the client surfaces a
non-blocking toast. When eventtypeid is not registered in
IPlatform.EventTypes the endpoint returns 404 Not Found with body
"EventType not found". The generator strategy (seed from the authored
static T Example, deep-clone, randomize, validate, retry up to 5 times,
fall back to the example) lives in
src/NimBus.WebApp/Services/FakeEventPayloadGenerator.cs; the singleton
registration is in Startup.cs.
Application
Process-wide platform stats and metadata about the running installation.
GET /api/app/stats is reachable without credentials so liveness probes and
status monitors keep working, but its payload is authenticated-only (GH#93):
environment name, exact platform version and backend topology would otherwise let
an unauthenticated scanner fingerprint the deployment.
Authenticated — the full shape:
curl -H "Authorization: Bearer $TOKEN" \
https://nimbus.example.com/api/app/stats
{
"env": "Production",
"platformVersion": "1.4.2",
"storageProvider": "Cosmos DB",
"ticketLinkTemplate": "https://support.example.com/browse/{ticket}"
}
Anonymous — 200 with an empty status object (every field null or absent):
curl https://nimbus.example.com/api/app/stats
{ "env": null, "platformVersion": null, "storageProvider": null, "ticketLinkTemplate": null }
All four fields are declared optional/nullable in api-spec.yaml, so generated
clients accept either shape.
Admin
Platform configuration and destructive maintenance operations (delete-by-To, platform-config rotation, stale-Pending reconcile). The largest tag — 42 operations. Treat with appropriate care; most operations require operator privileges.
McpAccess
Settings → MCP access (Spec 037): GET/PUT /api/admin/mcp/settings,
POST /api/admin/mcp/turn-off and GET /api/admin/mcp/activity. Site Owner only, under the
nimbus-admin rate limit. The writes require the X-NimBus-CSRF antiforgery token that GET
returns, and PUT is fenced by the policy revision (409 when stale). See
docs/mcp-server.md.
DashboardTokens
Settings → Dashboard tokens: GET/POST /api/admin/dashboard-tokens,
POST /api/admin/dashboard-tokens/{tokenId}/revoke and DELETE /api/admin/dashboard-tokens/{tokenId}.
Site Owner only, under the nimbus-admin rate limit; the writes require the X-NimBus-CSRF token
that GET returns. 501 when the storage provider cannot store tokens. See
Authentication → Dashboard tokens.
MonitorFeed
The Monitor wall's read API, for a site Reader or a dashboard token (X-NimBus-Dashboard-Token).
Every caller gets the same fleet-wide payload, unfiltered, composed once per cache window:
| Operation | Refresh | Holds |
|---|---|---|
GET /api/monitor/feed/status | 5 s | Every endpoint's status counts, estimated blocked sessions, heartbeat and acknowledgement; the environment and platform name; tokenAccess |
GET /api/monitor/feed/metrics | 30 s | 60 minute buckets of published, handled and failed; per endpoint and fleet-wide counts and average queue and processing time over the last 60 minutes |
GET /api/monitor/feed/history | 5 min | 72 hour buckets of the same counts; queue and processing time per hour for 24 hours; each endpoint's 24-hour average processing time |
Each response carries generatedAtUtc and stale: when the store cannot be read, the previous
result is returned with stale: true and the store is tried again once per refresh interval. With
nothing earlier to return the answer is 503. Responses are Cache-Control: no-store. A signed-in
caller without the site Reader role gets 403. The Monitor's existing ACK operations
(/api/monitor/acknowledgements) are unchanged and do not admit a token. See
The Monitor wall.
Message
Search the message history (/api/messages/search). Cursor-paginated.
Failed
Unresolved failures (Failed, DeadLettered, Unsupported) across the endpoints the caller
holds Reader on. Three operations, each taking a FailedSearchFilter (endpoints, statuses,
event types, IDs, From/To, error text, UpdatedAt range):
POST /api/failed/search— newest first, cursor-paginated (maxSearchItemsCountclamped to 500).POST /api/failed/histogram— counts per time bucket by status and endpoint, zero-filled. The bucket followsperiod(5 minutes for1h… one day for30d), or a customfrom/toof up to 90 days with at most 60 buckets.POST /api/failed/error-groups— the Insights grouping (category, then normalized pattern) with each pattern's failures; up to 5,000 failures,truncatedbeyond that.
Naming an endpoint the caller cannot read returns 403. See Failed messages page.
Audit
Search the per-event audit trail (/api/audits/search). Same pagination
shape as Message.
Metrics
Aggregations over the configurable period window (1h, 12h, 1d, 3d,
7d, 30d). Four operations: overview, latency, failed-insights,
timeseries. Example:
curl -H "Authorization: Bearer $TOKEN" \
"https://nimbus.example.com/api/metrics/overview?period=1d"
Failed insights
GET /api/metrics/failed-insights groups failure occurrences (one per failed attempt) by error
pattern. When saved failure classifications are readable it also groups them by likely cause
and by recovery guidance, and reports coverage: classified, uncertain, notAnalyzed and
notEligible (Spec 043). Reading it
never starts an analysis.
endpointIdnarrows the scope to one endpoint.refresh=truere-reads saved classifications; new failures can still lag by the 30 s metrics cache. The answer comes from a read that began after the request. Requests that refresh the same scope at the same time share that read, so they cost at most one read beyond the one already running.- The response returns the resolved
fromandto, in whole milliseconds (a custom window's bounds are truncated to the millisecond too). Send them back unchanged to page through the same occurrences. enrichmentisavailable,disabled,unavailable,unsupportedorlimitExceeded. Every value exceptavailableleavescoverage,causeGroupsandguidanceGroupsnull; it is never reported as zero coverage. When it isunavailable, the WebApp log has a warning fromFailureClassificationReaderwith the storage exception.analysisAvailableis false when new analysis can't be requested: intelligence is off, or only saved classifications can be read because no provider is configured.totalFailedstays for existing clients and always equalstotalOccurrences.
Two read-only routes page one group of the overview. Both take the overview's from and to,
the same endpointId, a perspective (errorPattern, cause or guidance), a groupKey from
that perspective, pageSize (1–100, default 50) and continuationToken:
GET /api/metrics/failed-insights/occurrences: the group's failed attempts, newest first, each with its analysis state, saved cause and guidance when there are any, anoperationMarker(analysisRunning,outcomeUnknown,lastAnalysisFailedoridentityMismatch) and the event'scurrentStatus, which is null when unavailable. Cause and guidance groups need saved classifications; error-pattern groups page without them.GET /api/metrics/failed-insights/patterns: a cause or guidance group's error patterns, most frequent first.
Group keys are valid only for the scope that issued them and are not durable across releases; a
key with no occurrences in the requested scope is a 400, in every perspective. A continuation
token is bound to its window, endpoint and group; reusing it elsewhere is a 400.
Pages are live reads, not a snapshot: new classifications can move occurrences between groups.
Limits, under NimBus:Insights:
| Setting | Default | Range | Meaning |
|---|---|---|---|
EnrichmentCeiling | 10000 | 100–100000 | Largest scope, after the endpoint filter, that is enriched; above it the response is limitExceeded |
EnrichmentCacheSeconds | 15 | 0–30 | Lifetime of a cached classification read. 0 turns the cache off: every request, including each drill-down page, reads classifications again |
BulkReadBatchSize | 500 | 50–1000 | Failures per classification-store call |
BulkReadConcurrency | 4 | 1–8 | Classification-store calls in flight |
TopPatternsPerGroup | 10 | 1–50 | Error patterns embedded in each cause or guidance group |
Out-of-range values stop the WebApp at startup.
Dev
Seed-data helpers used during local development. Not intended for production calls. Gated by operator authentication today; expect tightening.
StorageHook
A Cosmos DB change-feed receiver
(POST /api/storagehook/cosmos/{endpointId}) used to project storage events
into the resolver. Internal infrastructure surface; not for external callers.
Code generation
The spec is authoritative; both sides of the wire are generated from it.
- Server contracts: NSwag emits
Controllers/ApiContract.g.cspre-build via the MSBuild target inNimBus.WebApp.csproj:102-104. The generated interfaces are implemented by hand underControllers/ApiContract/*Implementation.cs. - Reference TypeScript client: the same NSwag run emits
ClientApp/src/api-client/index.ts(~338 KB; not published to npm). Manual overrides for auth/headers live inClientApp/src/api-client/index.extensions.ts. - External clients: point any OpenAPI generator at
src/NimBus.WebApp/api-spec.yaml(or the runtime copy served from the WebApp'swwwroot/api-spec.yamlin dev) to produce a client in your preferred language. The TS client in this repo is a usable reference.
Swagger UI is exposed only in the Development environment
(Startup.cs:416-420), via app.UseOpenApi(); app.UseSwaggerUi();. Production
hosts do not expose /swagger anonymously.
CORS
The current CORS policy (Startup.cs:399-402) allows credentials but
restricts origins to login.microsoftonline.com only. Browser-based external
callers from any other origin will be blocked by the browser; that's the
intended posture for now.
External integration today should be server-to-server: your service makes
the HTTPS call directly to https://<your-nimbus-host>/api/… with the bearer
token, bypassing browser CORS entirely. If your scenario requires browser
access from a third-party origin, see
the gap list.
What's not external-ready yet
Honest list of work needed before this API can be claimed as fully external-grade. Each item is its own focused workstream:
- API-key / service-principal auth — agents only. Agents have Owner-issued,
revocable API keys for
/api/agent/*(Spec 039). The rest of the API still needs a signed-in person or an Entra ID token; a general integrator key path with per-caller scopes is not implemented. ProblemDetailserror envelope (RFC 7807). Standardise error responses so callers can branch on a stable schema instead of per-endpoint shapes. Touches every*Implementation.csunderControllers/ApiContract/.- Rate limiting — partial. Four policies now protect the highest-cost
surfaces: a concurrency limiter on
GET /api/agent/receiveand fixed windows on/api/admin/*, the two search endpoints, andPOST /account/login. See Rate Limiting for values, bounds and tuning. Every other/api/*route is still unthrottled — there is no global limiter — and enforcement is per process, so a scaled-out deployment multiplies effective limits by instance count. - Public base URL in the spec.
api-spec.yaml:7lists onlyhttps://localhost:5001. A production server entry should be added so external generators produce clients pointing at the right host out of the box. - SemVer cut to
v1+ route versioning. Add/api/v1/…prefix (or document the chosen versioning strategy) and commit to a non-breaking change policy. - External CORS allow-list. Move from the single
login.microsoftonline.comorigin to a configuration-driven allow-list so browser-based external callers from approved origins can work.
Order in this list is roughly priority for shipping an "external" promise; the audit that produced it lives in the planning notes for the documentation work.
Audit
Spec 008 (centralized audit log service) requires every privileged-action
handler in the WebApp to call IAuditLogService.LogAuditAsync(...) exactly
once, on both the success and access-denied branches. The service writes the
audit row to two independent sinks:
- Durable — the configured
IMessageTrackingStore(Cosmos DB or SQL Server). Surfaced by the existing audit-list endpoints (GetMessageAudits,SearchAudits) and the WebApp Audits page. - Short-term operational — Application Insights, via a structured
ILoggerscope. The well-known message string is"Webapp AuditEvent occurred"so KQL queries can pivot on it directly.
Both writes are best-effort: a failure to either sink is logged as a warning
and absorbed — the user's action proceeds either way. The commands that change
a message are the exception: resubmit, resubmit-with-changes, skip and report
use IAuditLogService.LogRequiredAuditAsync, which writes the durable row
before the command runs and refuses the command (503) if it cannot.
Choosing which actions are recorded
A site Owner can switch individual audit types off in Settings → Audit logging
(GET/PUT /api/admin/audit/settings) — for example SearchEvents, which
fires on every list refresh. A disabled type is skipped by both sinks. Some
rows are always written regardless: access-denied attempts;
UpdateAuditSettings, which records every change to the selection; and the
commands that change a message (Resubmit, ResubmitWithChanges, Skip,
ReportEvent and CommandNotSent), which are not offered in the selection.
The selection is one record per platform (IEndpointMetadataStore.GetAuditSettings),
cached by each WebApp instance for 30 seconds; a save takes effect at once on
the instance that saved it. If the record cannot be read, every type is
recorded. Comment and Retry rows — event history written directly by the
event trail and the Resolver — are not governed by the setting.
Actions audited
The MessageAuditType enum enumerates the audit-worthy actions and the
controller method that fires each one:
AuditType | Triggered by |
|---|---|
Resubmit | EventImplementation.PostResubmitEventIdsAsync |
ResubmitWithChanges | EventImplementation.PostResubmitWithChangesEventIdsAsync |
Skip | EventImplementation.PostSkipEventIdsAsync |
CompleteHandoff | EventImplementation.PostHandoffCompleteAsync |
FailHandoff | EventImplementation.PostHandoffFailAsync |
SearchEvents | EventImplementation.PostApiEventEndpointIdGetByFilterAsync — filter passed as Data |
GetEventDetails | EventImplementation.GetEventDetailsIdAsync |
GetEndpointDetails | EndpointImplementation.GetEndpointStatusCountIdAsync |
EnableEndpoint | EndpointImplementation.PostEndpointSubscriptionstatusAsync (body="enable") |
DisableEndpoint | EndpointImplementation.PostEndpointSubscriptionstatusAsync (body="disable") |
PurgeMessages | EndpointImplementation.PostEndpointPurgeAsync, AdminImplementation.PostAdminSessionPurgeAsync, AdminImplementation.PostAdminPurgeAsync |
Compose | EventImplementation.PostComposeNewEventAsync |
ReconcileStalePending | AdminImplementation.PostAdminStalePendingReconcileAsync — one row per invocation (request + counts as Data) plus one per repaired event |
UpdateAuditSettings | AdminImplementation.PutAdminAuditSettingsAsync — the disabled types as Data; always recorded |
CommandNotSent | OperatorCommandCoordinator — a resubmit or skip was audited but publishing it failed, so the event was restored; always recorded |
UpdateMcpSettings | McpAccessImplementation.PutAdminMcpSettingsAsync / PostAdminMcpTurnOffAsync — the revisions and the list of changes as Data; always recorded |
McpAccessRefused | The operator MCP endpoint — a call refused by the MCP access policy or for a missing scope, with AccessDenied set; deduplicated per caller, client, reason and tool for 5 minutes per instance |
DashboardTokenCreated / DashboardTokenRevoked / DashboardTokenRemoved | DashboardTokensImplementation — the token id, name and expiry as Data, never the token; always recorded |
Resubmit, skip and resubmit-with-changes run through OperatorCommandCoordinator, shared with the
operator MCP tools. Their Data records the channel (WebApp or Mcp), the reason and idempotency
key an MCP caller gave, the client application, the prior status and the message version.
Retry and Comment remain on the enum for backward compatibility with rows
written before spec 008.
Audit row shape
Each row is a MessageAuditEntity:
AuditorName // resolved from "name" → ClaimTypes.Name → preferred_username → "anonymous"
AuditTimestamp // DateTime.UtcNow at the moment LogAuditAsync was called
AuditType // enum value (stored as string for forward compatibility)
Comment // operator-typed free text (null on the new code path)
AccessDenied // true when the authorization layer rejected the attempt
Data // structured action context (e.g. search filter JSON), truncated to ~4 KB
EventId // mirrors the eventId argument; null for endpoint-scoped actions
EndpointId // mirrors the endpointId argument
App Insights query template
traces
| where message == "Webapp AuditEvent occurred"
| project timestamp,
AuditType = tostring(customDimensions.AuditType),
AuditorName = tostring(customDimensions.AuditorName),
EventId = tostring(customDimensions.EventId),
EndpointId = tostring(customDimensions.EndpointId),
AccessDenied = tobool(customDimensions.AccessDenied),
Data = tostring(customDimensions.Data)
| order by timestamp desc
Guarantees and non-goals
- Best-effort, not transactional. A failure to write the audit row does not fail the user action. See spec 008 NFR-002 and User Story 5.
- One row per event for bulk actions. Not one row per batch — matches the audit-list UI's per-event granularity.
- No PII masking inside the service. Callers MUST pass already-masked
content via the
Dataparameter; masking is an upstream concern.
See also
docs/architecture.md— system-level shape and where the WebApp sits.docs/authentication.md— credential plumbing, Identity / Entra setup, bootstrap admin.src/NimBus.WebApp/api-spec.yaml— the machine-readable contract this page describes.src/NimBus.WebApp/Startup.cs— auth, CORS, and Swagger-UI configuration.