Skip to content

Add the custom-alert web API mirroring /api/views (#3285) - #3333

Merged
erikdarlingdata merged 1 commit into
devfrom
feature/3285-custom-alert-web-api
Sep 11, 2026
Merged

erikdarlingdata merged 1 commit into
devfrom
feature/3285-custom-alert-web-api

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Part of #3285.

The custom-alert web API — the backend half of Component 7's web editor. Erik reviews on mobile (no visual pass), so the API ships now and the JS editor UI stays deferred. MapCustomAlerts mirrors MapCustomViews route-for-route, reusing the same store / evaluator / templates the MCP tools use ("no new logic, just a web wrapper"):

Method Route Backed by
GET /api/alerts CustomAlertRuleStore.ListAsync (summaries, no definition)
GET /api/alerts/{id:long} CustomAlertRuleStore.GetAsync (full, 404 when missing)
POST /api/alerts create — CustomAlertRuleDefinition.TryParse before persist; 201 / 400 / 409
PUT /api/alerts/{id:long} update — same optimistic-concurrency/version handling as /api/views; 200 / 400 / 404 / 409
DELETE /api/alerts/{id:long} CustomAlertEvaluator.ResolveAndDeleteRuleAsync (not raw DeleteAsync) so an open incident force-resolves before the FK cascade (#3305); 204 / 404
GET /api/alert-templates CustomAlertTemplates.All
POST /api/alerts/validate dry-run TryParse → {valid, error}
POST /api/alerts/test evaluate-now — wraps test_custom_alert_rule

CustomAlertRuleResult maps onto the same HTTP codes /api/views maps CustomViewResult to.

How the writes are gated

The three mutations and the two evaluate POSTs (/validate, /test) require application/json (the CSRF gate) and are born gated by the host's method-based write gate: DarlingWebSeat.IsRequestAllowed refuses a read-only seat everything unsafe except POST /api/compose/run, so a viewer seat gets a 403 on every one of them without this layer naming the gate. /test runs the shared per-server scalar seam (EvaluateScalarNowAsync + ClassifyTestValue) on the host's viewer pool, exactly as /api/compose/run runs the composer — never the owner pool.

Shared, drift-free wire shape

The full-rule and summary builders now live once in DarlingWebEndpoints (BuildFullRuleNode / BuildRuleSummariesNode) and are called by the MCP alert tools, removing the private duplicates those tools carried — the same way /api/views shares its builders. Also corrects a stale DarlingMcpCustomAlertTools class-doc paragraph that still claimed evaluate-now was "deliberately absent" after #3299 shipped it.

Flag: pre-existing #3305 grant gap (NOT changed here)

ResolveAndDeleteRuleAsync writes the force-resolve recovery row via INSERT INTO config_alert_log, but neither the viewer nor the mcp role has INSERT on config_alert_log (only admin does). So on both the web (viewer-pool) DELETE and the already-shipped MCP (mcp-pool) delete, that INSERT fails permission-denied. It is failure-isolated (WriteTeardownResolutionAsync swallows and logs), so the delete still succeeds (204) and the rule stops firing — but the "Resolved" history row #3305 intends is silently skipped on both least-privilege paths. It is masked in #3305's live tests because they connect as owner. This is a security-surface decision (may the least-privilege roles write alert history?), so I did not change the grant here; it wants its own issue.

Tests

  • Extended DarlingWebOidcTests.IsRequestAllowed_Matrix with /api/alerts rows: CRUD writes + /validate + /test all born gated for a viewer; reads (list/get/templates) open; edit seat allowed.
  • Pinned the shared rule wire shape in DarlingWebEndpointsTests (enabled present; definition embedded as a JSON object, not an escaped string).
  • Fixed a pre-existing CS8620 nullability warning in CustomAlertTemplatesTests.
  • No MCP tool added, so the tool-inventory pins are unchanged.

Build & full-suite results

  • Service + tests build 0 warnings / 0 errors.
  • Darling.Tests: 8680 total, 0 errors, 3 failed, 345 skipped (live-gated). The 3 failures are the known worktree/platform baseline (FleetIdentifierScrubTests × 2 "scanned 0 files — the sweep lost the tree", NpgsqlRootCertificateValidationTests × 1 TLS), unrelated to this change.
  • Lite.Tests: 3703 total, 0 errors, 4 failed. All 4 are the known AuroraOnlySqlIsGatedTests worktree source-scan baseline; no Lite files were touched, and the cross-app MCP inventory pin passed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WejwpgWF5Xfm3fAmoEbFz4

Component 7's backend: the HTTP surface for the custom-alert-rule editor
(its JS deferred). MapCustomAlerts shadows MapCustomViews exactly, reusing
the SAME store/evaluator/templates the MCP tools use rather than a second
implementation:

  GET    /api/alerts            list rules (CustomAlertRuleStore.ListAsync)
  GET    /api/alerts/{id}       get one, full definition
  POST   /api/alerts            create (TryParse-validated before persist)
  PUT    /api/alerts/{id}       update (optimistic concurrency, same as views)
  DELETE /api/alerts/{id}       delete via ResolveAndDeleteRuleAsync (#3305:
                                force-resolve open incidents before the cascade)
  GET    /api/alert-templates   CustomAlertTemplates.All
  POST   /api/alerts/validate   dry-run TryParse -> {valid, error}
  POST   /api/alerts/test       evaluate-now, wraps test_custom_alert_rule

CustomAlertRuleResult maps onto the same HTTP codes /api/views maps
CustomViewResult to (200/201/204, 400, 404, 409). The writes plus the two
evaluate POSTs require application/json and are born gated by the host's
method-based write gate (a read-only seat is refused everything unsafe except
POST /api/compose/run), so a viewer seat gets a 403 without this layer naming
the gate. /test runs the shared per-server scalar seam on the host's VIEWER
pool, exactly as /api/compose/run runs the composer, never the owner pool.

The full-rule and summary wire builders are now shared once in
DarlingWebEndpoints (BuildFullRuleNode / BuildRuleSummariesNode) and called by
the MCP alert tools, removing the private duplicates they carried, so the web
and MCP rule shapes cannot drift (matching how /api/views shares its builders).
Also corrects a stale DarlingMcpCustomAlertTools class-doc paragraph that still
claimed evaluate-now was deliberately absent after #3299 shipped it.

Tests: extend the write-gate matrix with /api/alerts rows (CRUD + validate +
test born gated; reads open); pin the shared rule wire shape (enabled present,
definition embedded as an object). Fixes a pre-existing CS8620 nullability
warning in CustomAlertTemplatesTests. No MCP tool added, so the tool-inventory
pins are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WejwpgWF5Xfm3fAmoEbFz4
Comment on lines +2158 to +2164
/* Absent 'enabled' defaults true: create's default, and a full-replace PUT of a rule that names no
enabled state is an enabled rule (the real full-document editor always sends the field). */
var enabled = true;
if (obj["enabled"] is JsonValue enabledValue && enabledValue.TryGetValue<bool>(out var e))
{
enabled = e;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enabled on PUT /api/alerts/{id} diverges from the MCP surface's semantics for the same field, and fails open rather than rejecting bad input:

  • update_custom_alert_rule (MCP) is an explicit partial update — an omitted enabled preserves the row's current value (enabled ?? row.Enabled in DarlingMcpCustomAlertTools.UpdateCustomAlertRule).
  • This web PUT is a full replace, and an omitted (or type-mismatched, e.g. "enabled": "false" sent as a string) enabled silently resolves to true — i.e. a PUT that only touches name/description/definition on a paused rule will silently re-enable it.

The PR description frames this API as "no new logic, just a web wrapper" reusing the same store/evaluator as the MCP tools, but this is genuinely new behavior for the one field both surfaces share. The comment above justifies it by assuming "the real full-document editor always sends the field" — true once that (currently deferred) JS editor ships, but in the meantime this is a public, gated-but-reachable HTTP endpoint that any script/integration can PUT against today, with no test coverage exercising the omitted/malformed case.

Given this field gates whether an alert actually fires, consider either:

  • Making the web PUT a true full-replace only for the fields that were views-only before (name/description/definition), but treat enabled like version — require it explicitly and 400 when it's missing, rather than silently defaulting to true; or
  • Matching the MCP tool's partial-update semantics (enabled ?? current.Enabled) so a paused rule can't be silently resumed by an edit that never intended to touch that field.

@claude

claude Bot commented Sep 11, 2026

Copy link
Copy Markdown

Reviewed the custom-alert web API (MapCustomAlerts), the shared wire-shape builder move, and the OIDC gating matrix additions.

Overall: the route-for-route mirror of MapCustomViews, the shared BuildFullRuleNode/BuildRuleSummariesNode builders now used by both HTTP and MCP, the CSRF (application/json) gate, and the method-based write gate (DarlingWebSeat.IsRequestAllowed, unchanged and correctly self-covering the new routes) are all sound and consistent with existing patterns in this file. No T-SQL, no migration, and no Lite counterpart is expected — the custom-alert-rule feature and the web dashboard itself don't exist in Lite, so this isn't a parity gap.

One correctness concern (posted inline on DarlingWebEndpoints.cs): PUT /api/alerts/{id} treats enabled as full-replace-defaulting-to-true when absent or type-mismatched, whereas the MCP update_custom_alert_rule tool treats the same field as a partial update that preserves the current value when omitted. Since the two surfaces are pitched as sharing "no new logic, just a web wrapper," this asymmetry on the one field that gates whether a rule fires seems worth resolving explicitly (either require enabled on PUT like version, or make it default to the current value instead of true) rather than relying on a not-yet-built JS editor to always send it.

Everything else — request/definition validation ordering (validate before persist), 400/404/409 status mapping, delete routing through ResolveAndDeleteRuleAsync instead of raw DeleteAsync, and the pool selection (viewer pool throughout, never owner) — checked out against the store/evaluator code and the existing /api/views precedent.

The pre-existing config_alert_log grant gap you flagged in the PR description (viewer/mcp roles lack INSERT, so the force-resolve recovery row silently no-ops on non-owner connections) checks out against DarlingManagedRoles.cs — confirmed out of scope here, but worth actually filing the follow-up issue you mentioned so it doesn't get lost.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant