mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
backend: one typed error envelope (IApiErrorResponse) on every non-2xx body
Every error body now leads with { error, code, correlationId }: the generic
ApiErrorResponse (hint?, retryAfterSeconds?, operation?) plus the specialised
McpQuotaExceededResponse (reordered; `message` became `error`),
DelegatedSlotLimitReachedResponse (+correlationId) and the new
SessionDeletionRejectedResponse (replaces the anonymous delete arms). The
correlation id is stamped by the writers — ResponseHelper.ErrorAsync and the
new ApiErrorWriter for the middleware side (auth 401, policy 403/503, rate
limit 429, MCP quota 429, global exception 500) — never by call sites.
exceptionType and rateLimitInfo leave the wire; Constants.ApiErrorCodes is the
code vocabulary and is exported as the manifest section `apiErrorCodes`
(API_ERROR_CODES for web + MCP). CorrelationIdMiddleware names the readable
headers via Access-Control-Expose-Headers.
Guard: TypedResponseGuardTests no longer tolerates anonymous error shapes;
the 458 legacy sites in 149 files are frozen in WriteBaseline to melt down.
New facts: envelope-prefix reflection, ApiErrorWriter body/header pins,
exact-shape pins for the generic envelope and the changed specialised bodies.
Reverses the "error bodies stay anonymous" clause of D-043 (decision entry
follows with the docs stage).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The middleware-side error-envelope writer: status, content type, Retry-After and the exact body
|
||||
/// (production wire options — camelCase, absent-when-null) for the generic envelope and for a
|
||||
/// specialised <see cref="IApiErrorResponse"/>. Whatever the authentication, policy, rate-limit and
|
||||
/// quota middlewares write flows through here, so this is the one place their body shape is pinned.
|
||||
/// </summary>
|
||||
public class ApiErrorWriterTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Generic_envelope_writes_status_content_type_and_the_three_prefix_keys()
|
||||
{
|
||||
var ctx = NewContext();
|
||||
|
||||
await ApiErrorWriter.WriteAsync(ctx, "cid-42", HttpStatusCode.Unauthorized,
|
||||
Constants.ApiErrorCodes.AuthenticationRequired, "Authentication required.");
|
||||
|
||||
Assert.Equal(401, ctx.Response.StatusCode);
|
||||
Assert.StartsWith("application/json", ctx.Response.ContentType);
|
||||
Assert.False(ctx.Response.Headers.ContainsKey("Retry-After"));
|
||||
Assert.Equal(
|
||||
"{\"error\":\"Authentication required.\",\"code\":\"AuthenticationRequired\",\"correlationId\":\"cid-42\"}",
|
||||
Body(ctx));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Generic_envelope_with_retry_window_sets_the_header_and_mirrors_it_in_the_body()
|
||||
{
|
||||
var ctx = NewContext();
|
||||
|
||||
await ApiErrorWriter.WriteAsync(ctx, "cid-7", HttpStatusCode.TooManyRequests,
|
||||
Constants.ApiErrorCodes.RateLimited, "Rate limit exceeded: 60 requests per minute",
|
||||
retryAfterSeconds: 23);
|
||||
|
||||
Assert.Equal(429, ctx.Response.StatusCode);
|
||||
Assert.Equal("23", ctx.Response.Headers["Retry-After"]);
|
||||
Assert.Equal(
|
||||
"{\"error\":\"Rate limit exceeded: 60 requests per minute\",\"code\":\"RateLimited\",\"correlationId\":\"cid-7\",\"retryAfterSeconds\":23}",
|
||||
Body(ctx));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Generic_envelope_carries_hint_and_operation_only_when_given()
|
||||
{
|
||||
var ctx = NewContext();
|
||||
|
||||
await ApiErrorWriter.WriteAsync(ctx, "cid-9", HttpStatusCode.InternalServerError,
|
||||
Constants.ApiErrorCodes.InternalError, "QueryTable failed.",
|
||||
hint: "Retry with a narrower filter.", operation: "QueryTable");
|
||||
|
||||
Assert.Equal(
|
||||
"{\"error\":\"QueryTable failed.\",\"code\":\"InternalError\",\"correlationId\":\"cid-9\"," +
|
||||
"\"hint\":\"Retry with a narrower filter.\",\"operation\":\"QueryTable\"}",
|
||||
Body(ctx));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Specialised_body_is_stamped_with_the_correlation_id_and_serialized_as_its_runtime_type()
|
||||
{
|
||||
var ctx = NewContext();
|
||||
var body = new DelegatedSlotLimitReachedResponse
|
||||
{
|
||||
Error = "Slot limit reached.",
|
||||
HomeTenantId = "7aa20c11-0002-4b7c-a1d2-52f3aaaa0001",
|
||||
Used = 2,
|
||||
Limit = 2,
|
||||
Required = 1,
|
||||
};
|
||||
|
||||
await ApiErrorWriter.WriteAsync(ctx, "cid-slot", HttpStatusCode.Conflict, body);
|
||||
|
||||
Assert.Equal(409, ctx.Response.StatusCode);
|
||||
Assert.Equal("cid-slot", body.CorrelationId);
|
||||
var json = JsonDocument.Parse(Body(ctx)).RootElement;
|
||||
// Envelope prefix first, then the specialised fields — and nothing of the marker interface.
|
||||
var keys = json.EnumerateObject().Select(p => p.Name).ToArray();
|
||||
Assert.Equal(new[] { "error", "code", "correlationId", "homeTenantId", "used", "limit", "required" }, keys);
|
||||
Assert.Equal("DelegatedSlotLimitReached", json.GetProperty("code").GetString());
|
||||
}
|
||||
|
||||
private static DefaultHttpContext NewContext()
|
||||
{
|
||||
var ctx = new DefaultHttpContext();
|
||||
ctx.Response.Body = new MemoryStream();
|
||||
return ctx;
|
||||
}
|
||||
|
||||
private static string Body(HttpContext ctx)
|
||||
{
|
||||
ctx.Response.Body.Position = 0;
|
||||
using var reader = new StreamReader(ctx.Response.Body);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
@@ -29,6 +30,33 @@ public class ApiResponseWireParityTests
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
// ---- Error envelope (2026-09, deliberate wire change) ----------------------------------
|
||||
// Non-2xx bodies used to be ~10 anonymous shapes ({ success = false, message }, { error },
|
||||
// { error, message }, { error, code }, …). They are ONE typed envelope now, so these pins fix
|
||||
// the NEW shape exactly rather than proving parity with any single legacy literal.
|
||||
|
||||
[Fact]
|
||||
public void ApiErrorResponse_pins_the_minimal_envelope_shape()
|
||||
{
|
||||
var body = ApiErrorWriter.Build("cid-1", Constants.ApiErrorCodes.NotFound, "Session not found.");
|
||||
|
||||
Assert.Equal(
|
||||
"{\"error\":\"Session not found.\",\"code\":\"NotFound\",\"correlationId\":\"cid-1\"}",
|
||||
JsonSerializer.Serialize(body, WireOptions));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApiErrorResponse_pins_the_full_envelope_shape_and_key_order()
|
||||
{
|
||||
var body = ApiErrorWriter.Build("cid-2", Constants.ApiErrorCodes.ServiceUnavailable,
|
||||
"Authorization service temporarily unavailable.", hint: "Retry.", retryAfterSeconds: 5, operation: "PolicyEnforcement");
|
||||
|
||||
Assert.Equal(
|
||||
"{\"error\":\"Authorization service temporarily unavailable.\",\"code\":\"ServiceUnavailable\"," +
|
||||
"\"correlationId\":\"cid-2\",\"hint\":\"Retry.\",\"retryAfterSeconds\":5,\"operation\":\"PolicyEnforcement\"}",
|
||||
JsonSerializer.Serialize(body, WireOptions));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuccessMessageResponse_matches_the_canonical_anonymous_shape()
|
||||
{
|
||||
|
||||
@@ -27,6 +27,7 @@ public class DelegationWireParityTests
|
||||
{
|
||||
error = "Delegated tenant slot limit reached for partner.example: 2 of 2 slot(s) in use, 1 more needed. Raise the tenant's limit (plan package or Global Admin override) and retry.",
|
||||
code = "DelegatedSlotLimitReached",
|
||||
correlationId = "",
|
||||
homeTenantId = Home,
|
||||
homeTenantDomain = "partner.example",
|
||||
used = 2,
|
||||
@@ -47,6 +48,7 @@ public class DelegationWireParityTests
|
||||
{
|
||||
error = $"Delegated tenant slot limit reached for {Home}: 3 of 3 slot(s) in use, 2 more needed. Raise the tenant's limit (plan package or Global Admin override) and retry.",
|
||||
code = "DelegatedSlotLimitReached",
|
||||
correlationId = "",
|
||||
homeTenantId = Home,
|
||||
homeTenantDomain,
|
||||
used = 3,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
using System.Net;
|
||||
using System.Reflection;
|
||||
using AutopilotMonitor.Functions.Functions.Sessions;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services.Deletion;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
@@ -59,6 +60,9 @@ public class DeleteSessionFunctionTests
|
||||
}
|
||||
|
||||
// ── BuildV2ResponseBody ───────────────────────────────────────────────────────────────
|
||||
// The success arm is SessionDeletionQueuedResponse; every rejected arm is the typed error body
|
||||
// SessionDeletionRejectedResponse (envelope prefix error/code/correlationId + lock diagnostics).
|
||||
// The correlation id is stamped by the writer, so the builder leaves it empty here.
|
||||
|
||||
[Fact]
|
||||
public void BuildV2ResponseBody_Enqueued_carries_manifestId_and_queued_status()
|
||||
@@ -69,15 +73,15 @@ public class DeleteSessionFunctionTests
|
||||
ManifestId = ManifestId,
|
||||
};
|
||||
|
||||
var body = DeleteSessionFunction.BuildV2ResponseBody(result, SessionId);
|
||||
var body = Assert.IsType<SessionDeletionQueuedResponse>(DeleteSessionFunction.BuildV2ResponseBody(result, SessionId));
|
||||
|
||||
AssertProperty(body, "success", true);
|
||||
AssertProperty(body, "status", "queued");
|
||||
AssertProperty(body, "manifestId", ManifestId);
|
||||
Assert.True(body.Success);
|
||||
Assert.Equal("queued", body.Status);
|
||||
Assert.Equal(ManifestId, body.ManifestId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildV2ResponseBody_AlreadyInFlight_carries_state_and_manifestId_with_hint()
|
||||
public void BuildV2ResponseBody_AlreadyInFlight_carries_state_and_manifestId_with_code()
|
||||
{
|
||||
var result = new SessionDeletionEnqueueResult
|
||||
{
|
||||
@@ -86,12 +90,11 @@ public class DeleteSessionFunctionTests
|
||||
ExistingState = "Running",
|
||||
};
|
||||
|
||||
var body = DeleteSessionFunction.BuildV2ResponseBody(result, SessionId);
|
||||
var body = Rejected(DeleteSessionFunction.BuildV2ResponseBody(result, SessionId));
|
||||
|
||||
AssertProperty(body, "success", false);
|
||||
AssertProperty(body, "manifestId", ManifestId);
|
||||
AssertProperty(body, "deletionState", "Running");
|
||||
AssertProperty(body, "hint", "cascade_already_in_flight");
|
||||
Assert.Equal(Constants.ApiErrorCodes.CascadeAlreadyInFlight, body.Code);
|
||||
Assert.Equal(ManifestId, body.ManifestId);
|
||||
Assert.Equal("Running", body.DeletionState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -106,13 +109,12 @@ public class DeleteSessionFunctionTests
|
||||
ExistingState = "Poisoned",
|
||||
};
|
||||
|
||||
var body = DeleteSessionFunction.BuildV2ResponseBody(result, SessionId);
|
||||
var body = Rejected(DeleteSessionFunction.BuildV2ResponseBody(result, SessionId));
|
||||
|
||||
AssertProperty(body, "hint", "cascade_poisoned_use_restore");
|
||||
AssertProperty(body, "manifestId", ManifestId);
|
||||
Assert.Equal(Constants.ApiErrorCodes.CascadePoisonedUseRestore, body.Code);
|
||||
Assert.Equal(ManifestId, body.ManifestId);
|
||||
// The message string must mention the restore endpoint so the UI surfaces it verbatim.
|
||||
var message = GetProperty<string>(body, "message");
|
||||
Assert.Contains("/restore", message);
|
||||
Assert.Contains("/restore", body.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -126,10 +128,27 @@ public class DeleteSessionFunctionTests
|
||||
Outcome = SessionDeletionEnqueueOutcome.KillSwitchActive,
|
||||
};
|
||||
|
||||
var body = DeleteSessionFunction.BuildV2ResponseBody(result, SessionId);
|
||||
var body = Rejected(DeleteSessionFunction.BuildV2ResponseBody(result, SessionId));
|
||||
|
||||
AssertProperty(body, "hint", "kill_switch_active");
|
||||
Assert.False(HasProperty(body, "manifestId"));
|
||||
Assert.Equal(Constants.ApiErrorCodes.KillSwitchActive, body.Code);
|
||||
Assert.Null(body.ManifestId);
|
||||
Assert.DoesNotContain("manifestId", TestWire.Serialize(body));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildV2ResponseBody_rejected_arms_serialize_with_the_envelope_prefix()
|
||||
{
|
||||
var result = new SessionDeletionEnqueueResult
|
||||
{
|
||||
Outcome = SessionDeletionEnqueueOutcome.SessionNotFound,
|
||||
};
|
||||
|
||||
var body = Rejected(DeleteSessionFunction.BuildV2ResponseBody(result, SessionId));
|
||||
body.CorrelationId = "cid-1";
|
||||
|
||||
Assert.Equal(
|
||||
$"{{\"error\":\"Session {SessionId} not found\",\"code\":\"NotFound\",\"correlationId\":\"cid-1\"}}",
|
||||
TestWire.Serialize(body));
|
||||
}
|
||||
|
||||
// ── EvaluateAdminDeleteGates (kill-switch short-circuit) ──────────────────────────────
|
||||
@@ -141,10 +160,10 @@ public class DeleteSessionFunctionTests
|
||||
|
||||
Assert.NotNull(gate);
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, gate!.Value.Status);
|
||||
AssertProperty(gate.Value.Body, "hint", "kill_switch_active");
|
||||
Assert.Equal(Constants.ApiErrorCodes.KillSwitchActive, gate.Value.Body.Code);
|
||||
// The kill-switch body must NOT carry a manifestId so the UI doesn't render a
|
||||
// "track this cascade" link for a request that was refused.
|
||||
Assert.False(HasProperty(gate.Value.Body, "manifestId"));
|
||||
Assert.Null(gate.Value.Body.ManifestId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -157,25 +176,6 @@ public class DeleteSessionFunctionTests
|
||||
Assert.Null(gate);
|
||||
}
|
||||
|
||||
// ── Anonymous-object reflection helpers ───────────────────────────────────────────────
|
||||
|
||||
// IgnoreCase: the Enqueued arm is a typed DTO (PascalCase properties, camelCased on the
|
||||
// wire by ApiJsonOptions); the error arms stay anonymous (camelCase). Both resolve here.
|
||||
private static void AssertProperty(object body, string propertyName, object? expected)
|
||||
{
|
||||
var prop = body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
|
||||
Assert.NotNull(prop);
|
||||
Assert.Equal(expected, prop!.GetValue(body));
|
||||
}
|
||||
|
||||
private static T GetProperty<T>(object body, string propertyName)
|
||||
{
|
||||
var prop = body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
|
||||
Assert.NotNull(prop);
|
||||
var value = prop!.GetValue(body);
|
||||
return (T)value!;
|
||||
}
|
||||
|
||||
private static bool HasProperty(object body, string propertyName) =>
|
||||
body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance) != null;
|
||||
private static SessionDeletionRejectedResponse Rejected(IApiResponse body)
|
||||
=> Assert.IsType<SessionDeletionRejectedResponse>(body);
|
||||
}
|
||||
|
||||
@@ -641,6 +641,10 @@ public class InfrastructureWireParityTests
|
||||
}
|
||||
|
||||
// ---- McpQuotaEnforcementMiddleware: 429 quota body -----------------------------------
|
||||
// DELIBERATE wire change (error-envelope pass): the body now leads with the envelope prefix
|
||||
// error/code/correlationId (error carries the former `message`); quotaExceeded stays as the
|
||||
// MCP's discriminator. These literals pin the NEW shape (correlationId is stamped by the writer,
|
||||
// so the builder leaves it empty).
|
||||
|
||||
[Fact]
|
||||
public void McpQuotaExceededResponse_matches_the_daily_blocked_shape()
|
||||
@@ -654,14 +658,16 @@ public class InfrastructureWireParityTests
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
error = "MCP daily request quota exceeded for plan 'community'. The Community plan is sized for occasional use; Pro raises your daily and monthly windows. Resets at 2026-08-31T00:00:00Z.",
|
||||
code = "QuotaExceeded",
|
||||
correlationId = "",
|
||||
quotaExceeded = true,
|
||||
plan = "community",
|
||||
scope = "daily",
|
||||
level = "user",
|
||||
limit = 200,
|
||||
used = 200L,
|
||||
resetUtc = "2026-08-31T00:00:00Z",
|
||||
message = "MCP daily request quota exceeded for plan 'community'. The Community plan is sized for occasional use; Pro raises your daily and monthly windows. Resets at 2026-08-31T00:00:00Z."
|
||||
resetUtc = "2026-08-31T00:00:00Z"
|
||||
},
|
||||
McpQuotaEnforcementMiddleware.BuildExceededResponse(decision));
|
||||
}
|
||||
@@ -679,14 +685,16 @@ public class InfrastructureWireParityTests
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
error = "MCP monthly request quota of your organization exceeded (tenant plan 'pro', shared by all its members). Resets at 2026-09-01T00:00:00Z.",
|
||||
code = "QuotaExceeded",
|
||||
correlationId = "",
|
||||
quotaExceeded = true,
|
||||
plan = "power",
|
||||
scope = "monthly",
|
||||
level = "tenant",
|
||||
limit = 60000,
|
||||
used = 60000L,
|
||||
resetUtc = "2026-09-01T00:00:00Z",
|
||||
message = "MCP monthly request quota of your organization exceeded (tenant plan 'pro', shared by all its members). Resets at 2026-09-01T00:00:00Z."
|
||||
resetUtc = "2026-09-01T00:00:00Z"
|
||||
},
|
||||
McpQuotaEnforcementMiddleware.BuildExceededResponse(decision));
|
||||
}
|
||||
@@ -701,17 +709,20 @@ public class InfrastructureWireParityTests
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
error = "MCP request quota exceeded for plan 'community'. Resets at 2026-08-31T00:00:00Z.",
|
||||
code = "QuotaExceeded",
|
||||
correlationId = "",
|
||||
quotaExceeded = true,
|
||||
plan = "community",
|
||||
scope,
|
||||
level = "user",
|
||||
limit = 200,
|
||||
used = 200L,
|
||||
resetUtc = "2026-08-31T00:00:00Z",
|
||||
message = "MCP request quota exceeded for plan 'community'. Resets at 2026-08-31T00:00:00Z."
|
||||
resetUtc = "2026-08-31T00:00:00Z"
|
||||
},
|
||||
new McpQuotaExceededResponse
|
||||
{
|
||||
Error = "MCP request quota exceeded for plan 'community'. Resets at 2026-08-31T00:00:00Z.",
|
||||
QuotaExceeded = true,
|
||||
Plan = "community",
|
||||
Scope = null,
|
||||
@@ -719,7 +730,6 @@ public class InfrastructureWireParityTests
|
||||
Limit = 200,
|
||||
Used = 200,
|
||||
ResetUtc = "2026-08-31T00:00:00Z",
|
||||
Message = "MCP request quota exceeded for plan 'community'. Resets at 2026-08-31T00:00:00Z."
|
||||
});
|
||||
}
|
||||
|
||||
@@ -737,6 +747,9 @@ public class InfrastructureWireParityTests
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
error = "MCP daily request quota of the managed tenant 'customer.example' exceeded (tenant plan 'community', shared by all its members and delegated admins). That tenant is on the Community plan, which is sized for occasional use; its own plan governs this window, not yours. Upgrading that tenant to Pro lifts its organization windows. Resets at 2026-08-31T00:00:00Z.",
|
||||
code = "QuotaExceeded",
|
||||
correlationId = "",
|
||||
quotaExceeded = true,
|
||||
plan = "pro",
|
||||
scope = "daily",
|
||||
@@ -744,7 +757,6 @@ public class InfrastructureWireParityTests
|
||||
limit = 300,
|
||||
used = 300L,
|
||||
resetUtc = "2026-08-31T00:00:00Z",
|
||||
message = "MCP daily request quota of the managed tenant 'customer.example' exceeded (tenant plan 'community', shared by all its members and delegated admins). That tenant is on the Community plan, which is sized for occasional use; its own plan governs this window, not yours. Upgrading that tenant to Pro lifts its organization windows. Resets at 2026-08-31T00:00:00Z.",
|
||||
targetTenantId = "7aa20c11-0002-4b7c-a1d2-52f3aaaa0002"
|
||||
},
|
||||
McpQuotaEnforcementMiddleware.BuildExceededResponse(decision, "customer.example"));
|
||||
@@ -763,14 +775,16 @@ public class InfrastructureWireParityTests
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
error = "MCP request quota exceeded for all 2 managed tenants in scope (each managed tenant's own plan governs its organization windows; upgrading a managed tenant to Pro lifts them). Earliest reset at 2026-08-31T00:00:00Z.",
|
||||
code = "QuotaExceeded",
|
||||
correlationId = "",
|
||||
quotaExceeded = true,
|
||||
plan = "pro",
|
||||
scope = "daily",
|
||||
level = "tenant",
|
||||
limit = 300,
|
||||
used = 300L,
|
||||
resetUtc = "2026-08-31T00:00:00Z",
|
||||
message = "MCP request quota exceeded for all 2 managed tenants in scope (each managed tenant's own plan governs its organization windows; upgrading a managed tenant to Pro lifts them). Earliest reset at 2026-08-31T00:00:00Z."
|
||||
resetUtc = "2026-08-31T00:00:00Z"
|
||||
},
|
||||
McpQuotaEnforcementMiddleware.BuildExceededResponse(decision, exhaustedTenantCount: 2));
|
||||
}
|
||||
|
||||
+23
-23
@@ -198,17 +198,17 @@ public class McpQuotaEnforcementMiddlewareTests
|
||||
|
||||
Assert.Equal("tenant", body.Level);
|
||||
Assert.Equal(TenantB, body.TargetTenantId);
|
||||
Assert.Contains("managed tenant 'customer.example'", body.Message);
|
||||
Assert.Contains("tenant plan 'community'", body.Message);
|
||||
Assert.Contains("Upgrading that tenant to Pro", body.Message);
|
||||
Assert.DoesNotContain("your organization", body.Message);
|
||||
Assert.Contains("managed tenant 'customer.example'", body.Error);
|
||||
Assert.Contains("tenant plan 'community'", body.Error);
|
||||
Assert.Contains("Upgrading that tenant to Pro", body.Error);
|
||||
Assert.DoesNotContain("your organization", body.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManagedTarget_WithoutLabel_FallsBackToTheId()
|
||||
{
|
||||
var body = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("community", TenantB));
|
||||
Assert.Contains($"managed tenant '{TenantB}'", body.Message);
|
||||
Assert.Contains($"managed tenant '{TenantB}'", body.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -216,8 +216,8 @@ public class McpQuotaEnforcementMiddlewareTests
|
||||
{
|
||||
var body = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("pro", TenantB, 3000, 3000), "customer.example");
|
||||
Assert.Equal(TenantB, body.TargetTenantId);
|
||||
Assert.DoesNotContain("Upgrading", body.Message);
|
||||
Assert.Contains("shared by all its members and delegated admins", body.Message);
|
||||
Assert.DoesNotContain("Upgrading", body.Error);
|
||||
Assert.Contains("shared by all its members and delegated admins", body.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -225,8 +225,8 @@ public class McpQuotaEnforcementMiddlewareTests
|
||||
{
|
||||
var body = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("community", target: null));
|
||||
Assert.Null(body.TargetTenantId);
|
||||
Assert.Contains("of your organization", body.Message);
|
||||
Assert.DoesNotContain("managed tenant", body.Message);
|
||||
Assert.Contains("of your organization", body.Error);
|
||||
Assert.DoesNotContain("managed tenant", body.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -235,13 +235,13 @@ public class McpQuotaEnforcementMiddlewareTests
|
||||
// The quota is the upgrade lever: a member blocked by their own Community organization window is told
|
||||
// that Community is sized for occasional use and that Pro lifts it — Pro members never see the hint.
|
||||
var community = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("community", target: null));
|
||||
Assert.Contains("sized for occasional use", community.Message);
|
||||
Assert.Contains("upgrading your organization to Pro", community.Message);
|
||||
Assert.EndsWith("Resets at 2026-09-03T00:00:00Z.", community.Message);
|
||||
Assert.Contains("sized for occasional use", community.Error);
|
||||
Assert.Contains("upgrading your organization to Pro", community.Error);
|
||||
Assert.EndsWith("Resets at 2026-09-03T00:00:00Z.", community.Error);
|
||||
|
||||
var pro = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("pro", target: null, 3000, 3000));
|
||||
Assert.DoesNotContain("upgrading", pro.Message);
|
||||
Assert.DoesNotContain("occasional use", pro.Message);
|
||||
Assert.DoesNotContain("upgrading", pro.Error);
|
||||
Assert.DoesNotContain("occasional use", pro.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -249,9 +249,9 @@ public class McpQuotaEnforcementMiddlewareTests
|
||||
{
|
||||
// The delegated admin must learn that the CUSTOMER's plan (not their own Pro/MSP plan) governs the window.
|
||||
var body = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("community", TenantB), "customer.example");
|
||||
Assert.Contains("on the Community plan", body.Message);
|
||||
Assert.Contains("its own plan governs this window, not yours", body.Message);
|
||||
Assert.Contains("Upgrading that tenant to Pro", body.Message);
|
||||
Assert.Contains("on the Community plan", body.Error);
|
||||
Assert.Contains("its own plan governs this window, not yours", body.Error);
|
||||
Assert.Contains("Upgrading that tenant to Pro", body.Error);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -266,9 +266,9 @@ public class McpQuotaEnforcementMiddlewareTests
|
||||
dailyUsed: 100, monthlyUsed: 500, tenantDailyUsed: 1, tenantMonthlyUsed: 1, Now);
|
||||
var body = McpQuotaEnforcementMiddleware.BuildExceededResponse(decision);
|
||||
Assert.Equal("user", body.Level);
|
||||
Assert.StartsWith($"MCP daily request quota exceeded for plan '{userPlan}'.", body.Message);
|
||||
Assert.Equal(expectHint, body.Message.Contains("Pro raises your daily and monthly windows"));
|
||||
Assert.EndsWith("Resets at 2026-09-03T00:00:00Z.", body.Message);
|
||||
Assert.StartsWith($"MCP daily request quota exceeded for plan '{userPlan}'.", body.Error);
|
||||
Assert.Equal(expectHint, body.Error.Contains("Pro raises your daily and monthly windows"));
|
||||
Assert.EndsWith("Resets at 2026-09-03T00:00:00Z.", body.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -277,8 +277,8 @@ public class McpQuotaEnforcementMiddlewareTests
|
||||
var body = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("community", TenantB), exhaustedTenantCount: 3);
|
||||
Assert.Null(body.TargetTenantId);
|
||||
Assert.Equal("tenant", body.Level);
|
||||
Assert.Contains("all 3 managed tenants", body.Message);
|
||||
Assert.Contains("Earliest reset at 2026-09-03T00:00:00Z", body.Message);
|
||||
Assert.Contains("all 3 managed tenants", body.Error);
|
||||
Assert.Contains("Earliest reset at 2026-09-03T00:00:00Z", body.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -293,6 +293,6 @@ public class McpQuotaEnforcementMiddlewareTests
|
||||
Assert.Null(body.TargetTenantId);
|
||||
Assert.Equal(
|
||||
"MCP daily request quota exceeded for plan 'community'. The Community plan is sized for occasional use; Pro raises your daily and monthly windows. Resets at 2026-09-03T00:00:00Z.",
|
||||
body.Message);
|
||||
body.Error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,12 @@ public sealed class SharedManifestParityTests
|
||||
["opsEventTypes"] = OpsEventTypes.All,
|
||||
// Telemetry stores behind the operator KQL proxy (query_backend_logs `source`).
|
||||
["logSources"] = LogQuerySources.All,
|
||||
// Every machine-readable `code` an error envelope (IApiErrorResponse) can carry: the
|
||||
// generic catalog first, then the domain code classes. Declaration order kept.
|
||||
["apiErrorCodes"] = ConstStrings(typeof(Constants.ApiErrorCodes))
|
||||
.Concat(ConstStrings(typeof(Constants.DelegationCodes)))
|
||||
.Concat(ConstStrings(typeof(Constants.DelegatedSlots)))
|
||||
.ToArray(),
|
||||
["tenantRoles"] = ConstStrings(typeof(Constants.TenantRoles)),
|
||||
["globalRoles"] = ConstStrings(typeof(Constants.GlobalRoles)),
|
||||
["delegatedRoles"] = ConstStrings(typeof(Constants.DelegatedRoles)),
|
||||
|
||||
@@ -17,9 +17,11 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// match is a straight failure:
|
||||
/// - Regex A: <c>OkAsync/CreatedAsync/JsonAsync(new { ... })</c> through ResponseHelper
|
||||
/// (typed initializers <c>OkAsync(new SomeResponse { ... })</c> do not match),
|
||||
/// - Regex B: raw success <c>WriteAsJsonAsync(new { ... })</c>. Error bodies — first
|
||||
/// property <c>error</c>/<c>message</c>, or literal <c>success = false</c> — are
|
||||
/// tolerated and stay anonymous by design (one shape).
|
||||
/// - Regex B: raw <c>WriteAsJsonAsync(new { ... })</c> — since the error-envelope pass
|
||||
/// (2026-09) this covers ERROR bodies too: every non-2xx body is an
|
||||
/// <see cref="IApiErrorResponse"/> written by <c>ResponseHelper.ErrorAsync</c> /
|
||||
/// <c>ApiErrorWriter</c>, so the former error-shape tolerance is gone and the legacy
|
||||
/// anonymous error sites are frozen in <see cref="WriteBaseline"/> to melt down.
|
||||
/// Endpoints return typed DTOs implementing <see cref="IApiResponse"/>
|
||||
/// (AutopilotMonitor.Shared.Models, exported to TypeScript by SharedManifestParityTests),
|
||||
/// and each conversion carries an ordinal old-vs-new proof in the *WireParityTests files.
|
||||
@@ -41,11 +43,165 @@ public class TypedResponseGuardTests
|
||||
private static readonly Dictionary<string, int> HelperBaseline = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// EMPTY since 2026-08-31 — every anonymous success WriteAsJsonAsync body is typed (the
|
||||
/// migration started from the frozen 2026-08-30 baseline of 134 sites / 76 files). Any
|
||||
/// entry that would need to be ADDED here is a regression: return a typed DTO instead.
|
||||
/// Success bodies: EMPTY since 2026-08-31 (migrated from the frozen 2026-08-30 baseline of
|
||||
/// 134 sites / 76 files). Error bodies: frozen 2026-09-05 at 458 anonymous sites / 149 files
|
||||
/// when the error envelope (<see cref="IApiErrorResponse"/>) landed — every entry below is a
|
||||
/// legacy <c>{ success = false, message }</c> / <c>{ error }</c> literal awaiting migration to
|
||||
/// <c>req.ErrorAsync</c> / <c>req.BadRequestAsync</c> etc. Ratchet: a migrated file lowers or
|
||||
/// removes its entry; an entry that would need to be ADDED is a regression.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, int> WriteBaseline = new(StringComparer.OrdinalIgnoreCase);
|
||||
private static readonly Dictionary<string, int> WriteBaseline = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Functions/Admin/AutopilotDeviceValidationConsentFunction.cs"] = 1,
|
||||
["Functions/Admin/BackfillOccurredUtcFunction.cs"] = 3,
|
||||
["Functions/Admin/DelegatedAdminManagementFunction.cs"] = 7,
|
||||
["Functions/Admin/DelegatedSlotManagementFunction.cs"] = 1,
|
||||
["Functions/Admin/DelegationManagedTenantFunction.cs"] = 2,
|
||||
["Functions/Admin/DelegationSelfServiceFunction.cs"] = 2,
|
||||
["Functions/Admin/DeviceBlockFunction.cs"] = 4,
|
||||
["Functions/Admin/EmailTemplatesFunction.cs"] = 2,
|
||||
["Functions/Admin/GetAllBlockedDevicesFunction.cs"] = 2,
|
||||
["Functions/Admin/GetAuditLogsFunction.cs"] = 2,
|
||||
["Functions/Admin/GetGlobalAuditLogsFunction.cs"] = 2,
|
||||
["Functions/Admin/GetOpsEventsFunction.cs"] = 3,
|
||||
["Functions/Admin/IdentityBindingManagementFunction.cs"] = 3,
|
||||
["Functions/Admin/ReclassifyLegacySessionsFunction.cs"] = 4,
|
||||
["Functions/Admin/ReseedFromGitHubFunction.cs"] = 2,
|
||||
["Functions/Admin/SubmitOffboardingFeedbackFunction.cs"] = 4,
|
||||
["Functions/Admin/TenantAdminManagementFunction.cs"] = 14,
|
||||
["Functions/Admin/TenantGroupManagementFunction.cs"] = 10,
|
||||
["Functions/Admin/TenantOffboardFunction.cs"] = 2,
|
||||
["Functions/Admin/TriggerMaintenanceFunction.cs"] = 2,
|
||||
["Functions/Admin/VersionBlockFunction.cs"] = 4,
|
||||
["Functions/Annotations/GetSessionAnnotationsFunction.cs"] = 1,
|
||||
["Functions/Annotations/ListSessionAnnotationsFunction.cs"] = 3,
|
||||
["Functions/Annotations/ListTenantSessionAnnotationsFunction.cs"] = 3,
|
||||
["Functions/Annotations/UpsertSessionAnnotationFunction.cs"] = 4,
|
||||
["Functions/Apps/GetAppAnalyticsFunction.cs"] = 3,
|
||||
["Functions/Apps/GetAppSessionsFunction.cs"] = 3,
|
||||
["Functions/Apps/GetAppsListFunction.cs"] = 3,
|
||||
["Functions/Apps/GetGlobalAppAnalyticsFunction.cs"] = 4,
|
||||
["Functions/Apps/GetGlobalAppSessionsFunction.cs"] = 4,
|
||||
["Functions/Apps/GetGlobalAppsListFunction.cs"] = 4,
|
||||
["Functions/Bootstrap/BootstrapGetAgentConfigFunction.cs"] = 3,
|
||||
["Functions/Bootstrap/BootstrapRegisterSessionFunction.cs"] = 4,
|
||||
["Functions/Bootstrap/BootstrapReportAgentErrorFunction.cs"] = 1,
|
||||
["Functions/Bootstrap/CreateBootstrapSessionFunction.cs"] = 3,
|
||||
["Functions/Bootstrap/ListBootstrapSessionsFunction.cs"] = 1,
|
||||
["Functions/Bootstrap/RevokeBootstrapSessionFunction.cs"] = 3,
|
||||
["Functions/Bootstrap/ValidateBootstrapCodeFunction.cs"] = 5,
|
||||
["Functions/Config/AppHomingFunction.cs"] = 4,
|
||||
["Functions/Config/GetAdminConfigurationFunction.cs"] = 1,
|
||||
["Functions/Config/GetAgentConfigFunction.cs"] = 2,
|
||||
["Functions/Config/GetAllTenantConfigurationsFunction.cs"] = 3,
|
||||
["Functions/Config/GetLatestVersionsFunction.cs"] = 1,
|
||||
["Functions/Config/GetTenantConfigFieldsSchemaFunction.cs"] = 1,
|
||||
["Functions/Config/GetTenantConfigurationFunction.cs"] = 1,
|
||||
["Functions/Config/GetTenantFeatureFlagsFunction.cs"] = 1,
|
||||
["Functions/Config/ListTenantConfigBackupsFunction.cs"] = 1,
|
||||
["Functions/Config/PatchTenantConfigurationFieldsFunction.cs"] = 3,
|
||||
["Functions/Config/PlanManagementFunction.cs"] = 8,
|
||||
["Functions/Config/RevertTenantConfigurationFunction.cs"] = 2,
|
||||
["Functions/Config/TestOpsChannelFunction.cs"] = 1,
|
||||
["Functions/Config/TestWebhookNotificationFunction.cs"] = 2,
|
||||
["Functions/Config/UpdateAdminConfigurationFunction.cs"] = 5,
|
||||
["Functions/Config/UpdateTenantConfigurationFunction.cs"] = 5,
|
||||
["Functions/Diagnostics/DiagnosticsDownloadFunction.cs"] = 5,
|
||||
["Functions/Diagnostics/DiagnosticsDownloadTicketFunction.cs"] = 4,
|
||||
["Functions/Diagnostics/DiagnosticsTicketDownloadFunction.cs"] = 4,
|
||||
["Functions/Diagnostics/GetDiagnosticsPathsFunction.cs"] = 1,
|
||||
["Functions/Feedback/FeedbackFunction.cs"] = 5,
|
||||
["Functions/Global/GlobalNotificationsFunction.cs"] = 1,
|
||||
["Functions/Infrastructure/AuthFunction.cs"] = 7,
|
||||
["Functions/Infrastructure/McpUserFunction.cs"] = 9,
|
||||
["Functions/Infrastructure/SignalRAddToGroupFunction.cs"] = 9,
|
||||
["Functions/Infrastructure/SignalRNegotiateFunction.cs"] = 2,
|
||||
["Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs"] = 7,
|
||||
["Functions/Metrics/GetAppMetricsFunction.cs"] = 1,
|
||||
["Functions/Metrics/GetDeviceJourneyFunctions.cs"] = 4,
|
||||
["Functions/Metrics/GetFleetHealthMetricsFunction.cs"] = 1,
|
||||
["Functions/Metrics/GetGeographicLocationSessionsFunction.cs"] = 2,
|
||||
["Functions/Metrics/GetGeographicMetricsFunction.cs"] = 1,
|
||||
["Functions/Metrics/GetGlobalAgentEfficiencyFunction.cs"] = 2,
|
||||
["Functions/Metrics/GetGlobalAppMetricsFunction.cs"] = 1,
|
||||
["Functions/Metrics/GetGlobalFleetHealthMetricsFunction.cs"] = 1,
|
||||
["Functions/Metrics/GetGlobalGeographicLocationSessionsFunction.cs"] = 2,
|
||||
["Functions/Metrics/GetGlobalGeographicMetricsFunction.cs"] = 1,
|
||||
["Functions/Metrics/GetGlobalPlatformMetricsFunction.cs"] = 1,
|
||||
["Functions/Metrics/GetGlobalSlaMetricsFunction.cs"] = 2,
|
||||
["Functions/Metrics/GetImePatternHealthFunction.cs"] = 1,
|
||||
["Functions/Metrics/GetImeVersionHistoryFunction.cs"] = 1,
|
||||
["Functions/Metrics/GetPlatformStatsFunction.cs"] = 2,
|
||||
["Functions/Metrics/GetTimeAttributionFunctions.cs"] = 3,
|
||||
["Functions/Metrics/GetVerdictCalibrationFunction.cs"] = 2,
|
||||
["Functions/Metrics/McpUsageMetricsFunction.cs"] = 7,
|
||||
["Functions/Metrics/MetricsSummaryFunction.cs"] = 1,
|
||||
["Functions/Metrics/PlatformUsageMetricsFunction.cs"] = 1,
|
||||
["Functions/Metrics/RuleHitSessionsFunction.cs"] = 2,
|
||||
["Functions/Metrics/RuleStatsFunction.cs"] = 2,
|
||||
["Functions/Metrics/SlaMetricsFunction.cs"] = 1,
|
||||
["Functions/Metrics/UsageMetricsFunction.cs"] = 1,
|
||||
["Functions/Notifications/TenantNotificationsFunction.cs"] = 1,
|
||||
["Functions/Progress/ProgressPortalFunction.cs"] = 8,
|
||||
["Functions/Raw/AppInsightsQueryFunction.cs"] = 5,
|
||||
["Functions/Raw/QueryRawEventsFunction.cs"] = 7,
|
||||
["Functions/Raw/QueryRawSessionsFunction.cs"] = 3,
|
||||
["Functions/Raw/RawGlobalAdminGate.cs"] = 1,
|
||||
["Functions/Raw/TableQueryFunction.cs"] = 5,
|
||||
["Functions/Reports/GetDistressReportsFunction.cs"] = 1,
|
||||
["Functions/Reports/GetSessionReportDownloadUrlFunction.cs"] = 4,
|
||||
["Functions/Reports/GetSessionReportsFunction.cs"] = 3,
|
||||
["Functions/Reports/SessionReportDownloadTicketFunction.cs"] = 4,
|
||||
["Functions/Reports/SessionReportTicketDownloadFunction.cs"] = 4,
|
||||
["Functions/Reports/SubmitDiagFilesReportFunction.cs"] = 6,
|
||||
["Functions/Reports/SubmitSessionReportFunction.cs"] = 6,
|
||||
["Functions/Reports/UpdateSessionReportNoteFunction.cs"] = 4,
|
||||
["Functions/Rules/AnalyzeRulesFunction.cs"] = 12,
|
||||
["Functions/Rules/DryRunAnalyzeRuleFunction.cs"] = 2,
|
||||
["Functions/Rules/GatherRulesFunction.cs"] = 9,
|
||||
["Functions/Rules/GetRuleResultsFunction.cs"] = 1,
|
||||
["Functions/Rules/GlobalRulesFunction.cs"] = 5,
|
||||
["Functions/Rules/ImeLogPatternsFunction.cs"] = 4,
|
||||
["Functions/Rules/PreviewWhitelistFunction.cs"] = 10,
|
||||
["Functions/Rules/TestLogPatternFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetAllSessionStatsFunction.cs"] = 3,
|
||||
["Functions/Sessions/GetAllSessionsFunction.cs"] = 4,
|
||||
["Functions/Sessions/GetSessionDecisionGraphFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetSessionDeletePreviewFunction.cs"] = 4,
|
||||
["Functions/Sessions/GetSessionDeletionManifestFunction.cs"] = 4,
|
||||
["Functions/Sessions/GetSessionDeletionsListFunction.cs"] = 4,
|
||||
["Functions/Sessions/GetSessionEventsFunction.cs"] = 3,
|
||||
["Functions/Sessions/GetSessionFunction.cs"] = 2,
|
||||
["Functions/Sessions/GetSessionReducerVerificationFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetSessionSignalsFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetSessionStatsFunction.cs"] = 2,
|
||||
["Functions/Sessions/GetSessionsFunction.cs"] = 3,
|
||||
["Functions/Sessions/GetTenantDeletionManifestsFunction.cs"] = 2,
|
||||
["Functions/Sessions/GetTenantsWithDeletionManifestsFunction.cs"] = 1,
|
||||
["Functions/Sessions/MarkSessionFailedFunction.cs"] = 2,
|
||||
["Functions/Sessions/MarkSessionSucceededFunction.cs"] = 2,
|
||||
["Functions/Sessions/QueueSessionActionFunction.cs"] = 4,
|
||||
["Functions/Sessions/QuickSearchSessionsFunction.cs"] = 1,
|
||||
["Functions/Sessions/SearchSessionsByCveFunction.cs"] = 3,
|
||||
["Functions/Sessions/SearchSessionsByEventFunction.cs"] = 3,
|
||||
["Functions/Sessions/SearchSessionsFunction.cs"] = 4,
|
||||
["Functions/Vulnerability/AutoResolveCpeMappingFunction.cs"] = 3,
|
||||
["Functions/Vulnerability/DeleteCustomCpeMappingFunction.cs"] = 3,
|
||||
["Functions/Vulnerability/GetSoftwareInventoryFunction.cs"] = 1,
|
||||
["Functions/Vulnerability/GetTenantSoftwareInventoryFunction.cs"] = 1,
|
||||
["Functions/Vulnerability/GetVulnerabilityReportFunction.cs"] = 2,
|
||||
["Functions/Vulnerability/GetVulnerabilitySyncStatusFunction.cs"] = 1,
|
||||
["Functions/Vulnerability/IgnoreSoftwareFunction.cs"] = 5,
|
||||
["Functions/Vulnerability/SaveCustomCpeMappingFunction.cs"] = 2,
|
||||
["Functions/Vulnerability/TriggerEpssSyncFunction.cs"] = 2,
|
||||
["Functions/Vulnerability/TriggerMsrcSyncFunction.cs"] = 1,
|
||||
["Functions/Vulnerability/TriggerNvdCacheRefreshFunction.cs"] = 1,
|
||||
["Functions/Vulnerability/TriggerVulnerabilityDataSyncFunction.cs"] = 1,
|
||||
["Functions/Vulnerability/VulnerabilitySummaryFunction.cs"] = 1,
|
||||
["Helpers/TicketDownloadPrelude.cs"] = 3,
|
||||
["Security/SecurityValidationExtensions.cs"] = 3,
|
||||
["Services/Diagnostics/DiagnosticsBlobStreamer.cs"] = 2,
|
||||
};
|
||||
|
||||
// ── Bypass-shape guards (closed 2026-08-31) ─────────────────────────────────────────
|
||||
// Regexes A/B only see the INLINE literal `...(new { ... })`. Three bypass shapes let a
|
||||
@@ -73,20 +229,16 @@ public class TypedResponseGuardTests
|
||||
/// <summary>
|
||||
/// JsonSerializer.Serialize(new { ... }) sites that are NOT responses. EMPTY: the last entry (the
|
||||
/// App Insights query POST body) became a typed request record when the proxy learned sources.
|
||||
/// Everything must be a typed DTO (error shapes are tolerated by the shape check).
|
||||
/// Everything must be a typed DTO — error bodies included (IApiErrorResponse).
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, int> SerializeBaseline = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// A Build*/Compute*/*Payload/*Response method declared object hides its wire shape from
|
||||
/// every type check. The single tolerated site is BuildV2ResponseBody: its SUCCESS arm is
|
||||
/// the typed <c>SessionDeletionQueuedResponse</c>, but the method returns a union with the
|
||||
/// anonymous ERROR arms (tolerated by design), so its declared type must stay object.
|
||||
/// every type check. EMPTY: the last entry (DeleteSession's success/error union) returns
|
||||
/// <see cref="IApiResponse"/> since the error arms became <c>SessionDeletionRejectedResponse</c>.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, int> ObjectBuilderBaseline = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Functions/Sessions/DeleteSessionFunction.cs"] = 1,
|
||||
};
|
||||
private static readonly Dictionary<string, int> ObjectBuilderBaseline = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
[Fact]
|
||||
public void Anonymous_helper_success_bodies_never_exceed_the_frozen_baseline()
|
||||
@@ -98,13 +250,12 @@ public class TypedResponseGuardTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Anonymous_WriteAsJsonAsync_success_bodies_never_exceed_the_frozen_baseline()
|
||||
public void Anonymous_WriteAsJsonAsync_bodies_never_exceed_the_frozen_baseline()
|
||||
{
|
||||
AssertRatchet(
|
||||
text => AnonymousWriteAsJson.Matches(text)
|
||||
.Count(m => !IsErrorShape(text, m.Index + m.Length)),
|
||||
text => AnonymousWriteAsJson.Matches(text).Count,
|
||||
WriteBaseline,
|
||||
"anonymous success WriteAsJsonAsync(new { ... })");
|
||||
"anonymous WriteAsJsonAsync(new { ... })");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -116,22 +267,19 @@ public class TypedResponseGuardTests
|
||||
var ident = m.Groups[1].Value;
|
||||
if (ident == "new") return false;
|
||||
var assign = new Regex(@"(?:var\s+)?" + Regex.Escape(ident) + @"\s*=\s*new\s*\{");
|
||||
var a = assign.Match(text);
|
||||
// Tolerate error-shaped variables the same way inline literals are tolerated.
|
||||
return a.Success && !IsErrorShape(text, a.Index + a.Length);
|
||||
return assign.IsMatch(text);
|
||||
}),
|
||||
IdentifierBaseline,
|
||||
"WriteAsJsonAsync(<variable holding an anonymous object>)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Anonymous_JsonSerializer_Serialize_success_bodies_never_exceed_the_frozen_baseline()
|
||||
public void Anonymous_JsonSerializer_Serialize_bodies_never_exceed_the_frozen_baseline()
|
||||
{
|
||||
AssertRatchet(
|
||||
text => SerializeAnonymous.Matches(text)
|
||||
.Count(m => !IsErrorShape(text, m.Index + m.Length)),
|
||||
text => SerializeAnonymous.Matches(text).Count,
|
||||
SerializeBaseline,
|
||||
"anonymous success JsonSerializer.Serialize(new { ... })");
|
||||
"anonymous JsonSerializer.Serialize(new { ... })");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -173,6 +321,34 @@ public class TypedResponseGuardTests
|
||||
+ string.Join("\n ", nonFlat));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The error envelope is a PREFIX contract: every <see cref="IApiErrorResponse"/> implementer
|
||||
/// (generic <see cref="ApiErrorResponse"/> and the specialised bodies alike) declares
|
||||
/// <c>Error</c>, <c>Code</c>, <c>CorrelationId</c> as its first three properties, in that
|
||||
/// order, so any consumer can read the envelope from any error body without knowing the
|
||||
/// specialised type. Declaration order == wire order (see the flatness fact above).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Every_IApiErrorResponse_implementer_starts_with_the_envelope_prefix()
|
||||
{
|
||||
var implementers = typeof(IApiResponse).Assembly.GetTypes()
|
||||
.Where(t => typeof(IApiErrorResponse).IsAssignableFrom(t) && !t.IsInterface && !t.IsAbstract)
|
||||
.ToList();
|
||||
Assert.Contains(typeof(ApiErrorResponse), implementers);
|
||||
|
||||
var expected = new[] { nameof(IApiErrorResponse.Error), nameof(IApiErrorResponse.Code), nameof(IApiErrorResponse.CorrelationId) };
|
||||
var offenders = implementers
|
||||
.Select(t => (Type: t, Prefix: t.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
|
||||
.Select(p => p.Name).Take(3).ToArray()))
|
||||
.Where(x => !x.Prefix.SequenceEqual(expected))
|
||||
.Select(x => $"{x.Type.FullName}: [{string.Join(", ", x.Prefix)}]")
|
||||
.ToList();
|
||||
|
||||
Assert.True(offenders.Count == 0,
|
||||
"IApiErrorResponse implementers must declare Error, Code, CorrelationId first (wire prefix):\n "
|
||||
+ string.Join("\n ", offenders));
|
||||
}
|
||||
|
||||
// ── Object-slot ratchet (closed 2026-08-31, typisierung follow-up) ──────────────────
|
||||
// Regexes A-E police the FUNCTION side; this one polices the DTOs themselves: a Shared
|
||||
// wire type carrying an `object` (or collection-of-object) property hides that slot's
|
||||
@@ -268,58 +444,6 @@ public class TypedResponseGuardTests
|
||||
return element;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error shape = first property of the anonymous object is <c>error</c> or <c>message</c>
|
||||
/// (assigned or C# shorthand), or the literal <c>success = false</c>. Everything else —
|
||||
/// including <c>success = someExpression</c> (dual success/failure sites) — counts as a
|
||||
/// success body that must become a typed DTO.
|
||||
/// </summary>
|
||||
private static bool IsErrorShape(string text, int afterBraceIndex)
|
||||
{
|
||||
var i = SkipTrivia(text, afterBraceIndex);
|
||||
var start = i;
|
||||
while (i < text.Length && (char.IsLetterOrDigit(text[i]) || text[i] == '_'))
|
||||
i++;
|
||||
var identifier = text.Substring(start, i - start);
|
||||
|
||||
if (identifier is "error" or "message")
|
||||
return true;
|
||||
|
||||
if (identifier == "success")
|
||||
{
|
||||
i = SkipTrivia(text, i);
|
||||
if (i < text.Length && text[i] == '=' && (i + 1 >= text.Length || text[i + 1] != '='))
|
||||
{
|
||||
i = SkipTrivia(text, i + 1);
|
||||
if (string.CompareOrdinal(text, i, "false", 0, 5) == 0 &&
|
||||
(i + 5 >= text.Length || !char.IsLetterOrDigit(text[i + 5])))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int SkipTrivia(string text, int i)
|
||||
{
|
||||
while (i < text.Length)
|
||||
{
|
||||
if (char.IsWhiteSpace(text[i]))
|
||||
{
|
||||
i++;
|
||||
}
|
||||
else if (text[i] == '/' && i + 1 < text.Length && text[i + 1] == '/')
|
||||
{
|
||||
while (i < text.Length && text[i] != '\n') i++;
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
private static void AssertRatchet(
|
||||
Func<string, int> countSites, Dictionary<string, int> baseline, string label)
|
||||
{
|
||||
|
||||
+1
-1
@@ -185,7 +185,7 @@ public class IdentityBindingRequest
|
||||
}
|
||||
|
||||
/// <summary>Error code the grant endpoints return (HTTP 422) when the home tenant could not be resolved automatically.</summary>
|
||||
public const string HomeTenantUnresolvedCode = "HomeTenantUnresolved";
|
||||
public const string HomeTenantUnresolvedCode = AutopilotMonitor.Shared.Constants.ApiErrorCodes.HomeTenantUnresolved;
|
||||
public const string HomeTenantUnresolvedMessage =
|
||||
"The person's home tenant could not be resolved automatically (no previous sign-in, and the UPN domain does not belong to an onboarded tenant). Select the tenant they sign in from.";
|
||||
}
|
||||
|
||||
+2
-4
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services.Backup;
|
||||
using AutopilotMonitor.Shared.Models.Backup;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
@@ -34,10 +35,7 @@ namespace AutopilotMonitor.Functions.Functions.Backup
|
||||
var (payload, _) = await _store.ReadManifestAsync(backupId, req.FunctionContext.CancellationToken);
|
||||
if (payload is null)
|
||||
{
|
||||
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
|
||||
notFound.Headers.Add("Content-Type", "application/json; charset=utf-8");
|
||||
await notFound.WriteStringAsync(JsonSerializer.Serialize(new { error = "ManifestNotFound", backupId }, BackupManifestJson.SerializerOptions));
|
||||
return notFound;
|
||||
return await req.NotFoundAsync($"Backup manifest {backupId} not found.");
|
||||
}
|
||||
|
||||
// Stream the bytes verbatim — the manifest is already JSON, no need to deserialise + re-serialise.
|
||||
|
||||
@@ -201,15 +201,11 @@ namespace AutopilotMonitor.Functions.Functions.Raw
|
||||
}
|
||||
catch (RequestFailedException rfe)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, rfe,
|
||||
$"Query table '{tableName}'",
|
||||
new { tableName, filter = req.Query["filter"], partitionKey = req.Query["partitionKey"] });
|
||||
return await req.InternalServerErrorAsync(_logger, rfe, $"Query table '{tableName}'");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex,
|
||||
$"Query table '{tableName}'",
|
||||
new { tableName });
|
||||
return await req.InternalServerErrorAsync(_logger, ex, $"Query table '{tableName}'");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+39
-45
@@ -2,6 +2,7 @@ using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Deletion;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Deletion;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
@@ -67,7 +68,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
_logger.LogWarning(
|
||||
"DeleteSession rejected: SessionDeletionKillSwitch is active. tenant={TenantId} session={SessionId}",
|
||||
tenantId, sessionId);
|
||||
return await WriteResponse(req, gateResult.Value.Status, gateResult.Value.Body);
|
||||
return await req.ErrorAsync(gateResult.Value.Status, gateResult.Value.Body);
|
||||
}
|
||||
|
||||
// Gate passed — hand off to the V2 cascade producer (handles 404, locked states,
|
||||
@@ -76,13 +77,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"Error deleting session {sessionId}");
|
||||
|
||||
return await WriteResponse(req, HttpStatusCode.InternalServerError, new
|
||||
{
|
||||
success = false,
|
||||
message = "Internal server error",
|
||||
});
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "DeleteSession");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,15 +91,14 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
/// producer's recovery and strand admin-triggered cascades that crashed mid-flight.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static (HttpStatusCode Status, object Body)? EvaluateAdminDeleteGates(bool killSwitchActive)
|
||||
internal static (HttpStatusCode Status, SessionDeletionRejectedResponse Body)? EvaluateAdminDeleteGates(bool killSwitchActive)
|
||||
{
|
||||
if (killSwitchActive)
|
||||
{
|
||||
return (HttpStatusCode.ServiceUnavailable, new
|
||||
return (HttpStatusCode.ServiceUnavailable, new SessionDeletionRejectedResponse
|
||||
{
|
||||
success = false,
|
||||
message = "Session deletion is temporarily disabled by global kill-switch.",
|
||||
hint = "kill_switch_active",
|
||||
Error = "Session deletion is temporarily disabled by global kill-switch.",
|
||||
Code = Constants.ApiErrorCodes.KillSwitchActive,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -171,7 +165,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
/// Builds the JSON body for a V2-enqueue response. Internal so the test project
|
||||
/// can assert the shape without going through HttpResponseData mock plumbing.
|
||||
/// </summary>
|
||||
internal static object BuildV2ResponseBody(SessionDeletionEnqueueResult result, string sessionId) => result.Outcome switch
|
||||
internal static IApiResponse BuildV2ResponseBody(SessionDeletionEnqueueResult result, string sessionId) => result.Outcome switch
|
||||
{
|
||||
SessionDeletionEnqueueOutcome.Enqueued => new SessionDeletionQueuedResponse
|
||||
{
|
||||
@@ -180,51 +174,51 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
ManifestId = result.ManifestId,
|
||||
Message = "Cascade deletion queued; worker will drain asynchronously.",
|
||||
},
|
||||
SessionDeletionEnqueueOutcome.AlreadyInFlight => new
|
||||
SessionDeletionEnqueueOutcome.AlreadyInFlight => new SessionDeletionRejectedResponse
|
||||
{
|
||||
success = false,
|
||||
message = "A cascade for this session is already in flight.",
|
||||
deletionState = result.ExistingState,
|
||||
manifestId = result.ManifestId,
|
||||
hint = "cascade_already_in_flight",
|
||||
Error = "A cascade for this session is already in flight.",
|
||||
Code = Constants.ApiErrorCodes.CascadeAlreadyInFlight,
|
||||
DeletionState = result.ExistingState,
|
||||
ManifestId = result.ManifestId,
|
||||
},
|
||||
SessionDeletionEnqueueOutcome.Poisoned => new
|
||||
SessionDeletionEnqueueOutcome.Poisoned => new SessionDeletionRejectedResponse
|
||||
{
|
||||
success = false,
|
||||
message = "Cascade is poisoned; recover via POST /api/global/sessions/{id}/restore before retrying delete.",
|
||||
deletionState = result.ExistingState,
|
||||
manifestId = result.ManifestId,
|
||||
hint = "cascade_poisoned_use_restore",
|
||||
Error = "Cascade is poisoned; recover via POST /api/global/sessions/{id}/restore before retrying delete.",
|
||||
Code = Constants.ApiErrorCodes.CascadePoisonedUseRestore,
|
||||
DeletionState = result.ExistingState,
|
||||
ManifestId = result.ManifestId,
|
||||
},
|
||||
SessionDeletionEnqueueOutcome.KillSwitchActive => new
|
||||
SessionDeletionEnqueueOutcome.KillSwitchActive => new SessionDeletionRejectedResponse
|
||||
{
|
||||
success = false,
|
||||
message = "Session deletion is temporarily disabled by global kill-switch.",
|
||||
hint = "kill_switch_active",
|
||||
Error = "Session deletion is temporarily disabled by global kill-switch.",
|
||||
Code = Constants.ApiErrorCodes.KillSwitchActive,
|
||||
},
|
||||
SessionDeletionEnqueueOutcome.CasExhausted => new
|
||||
SessionDeletionEnqueueOutcome.CasExhausted => new SessionDeletionRejectedResponse
|
||||
{
|
||||
success = false,
|
||||
message = "Cascade enqueue exhausted retries; please retry shortly.",
|
||||
hint = "cas_exhausted_retry_later",
|
||||
Error = "Cascade enqueue exhausted retries; please retry shortly.",
|
||||
Code = Constants.ApiErrorCodes.CasExhaustedRetryLater,
|
||||
},
|
||||
SessionDeletionEnqueueOutcome.SessionNotFound => new
|
||||
SessionDeletionEnqueueOutcome.SessionNotFound => new SessionDeletionRejectedResponse
|
||||
{
|
||||
success = false,
|
||||
message = $"Session {sessionId} not found",
|
||||
Error = $"Session {sessionId} not found",
|
||||
Code = Constants.ApiErrorCodes.NotFound,
|
||||
},
|
||||
_ => new
|
||||
_ => new SessionDeletionRejectedResponse
|
||||
{
|
||||
success = false,
|
||||
message = "Internal server error",
|
||||
Error = "Internal server error",
|
||||
Code = Constants.ApiErrorCodes.InternalError,
|
||||
},
|
||||
};
|
||||
|
||||
private static async Task<HttpResponseData> WriteResponse(HttpRequestData req, HttpStatusCode status, object body)
|
||||
/// <summary>
|
||||
/// Dispatch on the concrete body type: serializing through the <see cref="IApiResponse"/>
|
||||
/// marker would emit an empty object (System.Text.Json writes the declared type).
|
||||
/// </summary>
|
||||
private static Task<HttpResponseData> WriteResponse(HttpRequestData req, HttpStatusCode status, IApiResponse body) => body switch
|
||||
{
|
||||
var response = req.CreateResponse(status);
|
||||
await response.WriteAsJsonAsync(body);
|
||||
return response;
|
||||
}
|
||||
SessionDeletionRejectedResponse rejected => req.ErrorAsync(status, rejected),
|
||||
SessionDeletionQueuedResponse queued => req.JsonAsync(status, queued),
|
||||
_ => throw new InvalidOperationException($"Unexpected DeleteSession body {body.GetType().Name}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+2
-4
@@ -4,6 +4,7 @@ using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
@@ -112,10 +113,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get CPE mappings");
|
||||
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await errorResponse.WriteStringAsync("Internal server error");
|
||||
return errorResponse;
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "GetCpeMappings");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-4
@@ -68,10 +68,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get software inventory");
|
||||
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await errorResponse.WriteStringAsync("Internal server error");
|
||||
return errorResponse;
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "GetSoftwareInventory");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// The error-envelope writer for the middleware side of the pipeline, where only the ASP.NET Core
|
||||
/// <see cref="HttpContext"/> exists (no <c>HttpRequestData</c>). Same body, same serializer settings
|
||||
/// (<see cref="ApiJsonOptions.Instance"/>) as <see cref="ResponseHelper.ErrorAsync"/>, so a 401 from
|
||||
/// the authentication middleware and a 400 from a function are indistinguishable in shape.
|
||||
/// The correlation id is stamped here — call sites never fetch it.
|
||||
/// </summary>
|
||||
public static class ApiErrorWriter
|
||||
{
|
||||
/// <summary>Build the generic envelope. Pure; shared by both writers and by tests.</summary>
|
||||
public static ApiErrorResponse Build(
|
||||
string correlationId, string code, string message, string? hint = null,
|
||||
int? retryAfterSeconds = null, string? operation = null)
|
||||
=> new()
|
||||
{
|
||||
Error = message,
|
||||
Code = code,
|
||||
CorrelationId = correlationId,
|
||||
Hint = hint,
|
||||
RetryAfterSeconds = retryAfterSeconds,
|
||||
Operation = operation,
|
||||
};
|
||||
|
||||
/// <summary>Write the generic envelope; sets <c>Retry-After</c> when <paramref name="retryAfterSeconds"/> is given.</summary>
|
||||
public static Task WriteAsync(
|
||||
HttpContext httpContext, string correlationId, HttpStatusCode status, string code, string message,
|
||||
string? hint = null, int? retryAfterSeconds = null, string? operation = null)
|
||||
=> WriteAsync(httpContext, correlationId, status,
|
||||
Build(correlationId, code, message, hint, retryAfterSeconds, operation), retryAfterSeconds);
|
||||
|
||||
/// <summary>Write a specialised error body; stamps its correlation id first.</summary>
|
||||
public static async Task WriteAsync<T>(
|
||||
HttpContext httpContext, string correlationId, HttpStatusCode status, T body, int? retryAfterSeconds = null)
|
||||
where T : class, IApiErrorResponse
|
||||
{
|
||||
body.CorrelationId = correlationId;
|
||||
httpContext.Response.StatusCode = (int)status;
|
||||
httpContext.Response.ContentType = "application/json";
|
||||
if (retryAfterSeconds is int seconds)
|
||||
httpContext.Response.Headers["Retry-After"] = seconds.ToString();
|
||||
await httpContext.Response.WriteAsJsonAsync(body, body.GetType(), ApiJsonOptions.Instance);
|
||||
}
|
||||
}
|
||||
@@ -27,4 +27,11 @@ public static class ApiJsonOptions
|
||||
Apply(options);
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared read-only instance for writers outside the worker serializer (middleware writing
|
||||
/// through the ASP.NET Core <c>HttpResponse</c>), so a middleware body and a function body
|
||||
/// serialize identically.
|
||||
/// </summary>
|
||||
public static JsonSerializerOptions Instance { get; } = Create();
|
||||
}
|
||||
|
||||
@@ -22,10 +22,6 @@ public static class DelegatedSlotResponses
|
||||
};
|
||||
}
|
||||
|
||||
public static async Task<HttpResponseData> ConflictAsync(HttpRequestData req, DelegatedSlotViolation violation)
|
||||
{
|
||||
var conflict = req.CreateResponse(HttpStatusCode.Conflict);
|
||||
await conflict.WriteAsJsonAsync(Build(violation));
|
||||
return conflict;
|
||||
}
|
||||
public static Task<HttpResponseData> ConflictAsync(HttpRequestData req, DelegatedSlotViolation violation)
|
||||
=> req.ErrorAsync(HttpStatusCode.Conflict, Build(violation));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
@@ -39,89 +40,95 @@ public static class ResponseHelper
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>400 Bad Request with <c>{ "error": message }</c>.</summary>
|
||||
public static async Task<HttpResponseData> BadRequestAsync(this HttpRequestData req, string message)
|
||||
// ── Error envelope ──────────────────────────────────────────────────────────────────
|
||||
// Every non-2xx body is an IApiErrorResponse: { error, code, correlationId, ... }. The
|
||||
// correlation id is stamped HERE, never at the call site. ApiErrorWriter writes the same
|
||||
// envelope on the middleware side (HttpContext). Codes: Constants.ApiErrorCodes unless a
|
||||
// domain class owns the value.
|
||||
|
||||
/// <summary>Generic error envelope with an explicit status; sets <c>Retry-After</c> when given.</summary>
|
||||
public static Task<HttpResponseData> ErrorAsync(
|
||||
this HttpRequestData req, HttpStatusCode status, string code, string message,
|
||||
string? hint = null, int? retryAfterSeconds = null)
|
||||
{
|
||||
var response = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await response.WriteAsJsonAsync(new { error = message });
|
||||
var body = ApiErrorWriter.Build(req.FunctionContext.GetCorrelationId(), code, message, hint, retryAfterSeconds);
|
||||
return ErrorAsync(req, status, body, retryAfterSeconds);
|
||||
}
|
||||
|
||||
/// <summary>Specialised error body (e.g. <see cref="DelegatedSlotLimitReachedResponse"/>); stamps its correlation id.</summary>
|
||||
public static async Task<HttpResponseData> ErrorAsync<T>(
|
||||
this HttpRequestData req, HttpStatusCode status, T body, int? retryAfterSeconds = null)
|
||||
where T : class, IApiErrorResponse
|
||||
{
|
||||
body.CorrelationId = req.FunctionContext.GetCorrelationId();
|
||||
var response = req.CreateResponse(status);
|
||||
if (retryAfterSeconds is int seconds)
|
||||
response.Headers.Add("Retry-After", seconds.ToString());
|
||||
await response.WriteAsJsonAsync(body);
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>401 Unauthorized with <c>{ "error": message }</c>.</summary>
|
||||
public static async Task<HttpResponseData> UnauthorizedAsync(this HttpRequestData req, string message)
|
||||
{
|
||||
var response = req.CreateResponse(HttpStatusCode.Unauthorized);
|
||||
await response.WriteAsJsonAsync(new { error = message });
|
||||
return response;
|
||||
}
|
||||
/// <summary>400 Bad Request envelope.</summary>
|
||||
public static Task<HttpResponseData> BadRequestAsync(
|
||||
this HttpRequestData req, string message, string code = Constants.ApiErrorCodes.BadRequest, string? hint = null)
|
||||
=> ErrorAsync(req, HttpStatusCode.BadRequest, code, message, hint);
|
||||
|
||||
/// <summary>403 Forbidden with <c>{ "error": message }</c>.</summary>
|
||||
public static async Task<HttpResponseData> ForbiddenAsync(this HttpRequestData req, string message)
|
||||
{
|
||||
var response = req.CreateResponse(HttpStatusCode.Forbidden);
|
||||
await response.WriteAsJsonAsync(new { error = message });
|
||||
return response;
|
||||
}
|
||||
/// <summary>401 Unauthorized envelope.</summary>
|
||||
public static Task<HttpResponseData> UnauthorizedAsync(
|
||||
this HttpRequestData req, string message, string code = Constants.ApiErrorCodes.Unauthorized, string? hint = null)
|
||||
=> ErrorAsync(req, HttpStatusCode.Unauthorized, code, message, hint);
|
||||
|
||||
/// <summary>404 Not Found with <c>{ "error": message }</c>.</summary>
|
||||
public static async Task<HttpResponseData> NotFoundAsync(this HttpRequestData req, string message)
|
||||
{
|
||||
var response = req.CreateResponse(HttpStatusCode.NotFound);
|
||||
await response.WriteAsJsonAsync(new { error = message });
|
||||
return response;
|
||||
}
|
||||
/// <summary>403 Forbidden envelope.</summary>
|
||||
public static Task<HttpResponseData> ForbiddenAsync(
|
||||
this HttpRequestData req, string message, string code = Constants.ApiErrorCodes.Forbidden, string? hint = null)
|
||||
=> ErrorAsync(req, HttpStatusCode.Forbidden, code, message, hint);
|
||||
|
||||
/// <summary>404 Not Found envelope.</summary>
|
||||
public static Task<HttpResponseData> NotFoundAsync(
|
||||
this HttpRequestData req, string message, string code = Constants.ApiErrorCodes.NotFound, string? hint = null)
|
||||
=> ErrorAsync(req, HttpStatusCode.NotFound, code, message, hint);
|
||||
|
||||
/// <summary>409 Conflict envelope.</summary>
|
||||
public static Task<HttpResponseData> ConflictAsync(
|
||||
this HttpRequestData req, string message, string code = Constants.ApiErrorCodes.Conflict, string? hint = null)
|
||||
=> ErrorAsync(req, HttpStatusCode.Conflict, code, message, hint);
|
||||
|
||||
/// <summary>
|
||||
/// 500 Internal Server Error with structured, AI-consumable error details.
|
||||
/// Returns exception type, sanitized message, correlation ID, and operation context.
|
||||
/// MCP clients (identified by <c>X-Client-Source: mcp</c>) receive richer details
|
||||
/// including recovery hints and request context to help the AI self-correct.
|
||||
/// Stack traces and infrastructure secrets are never exposed.
|
||||
/// 500 Internal Server Error envelope (<c>code = InternalError</c>) with a sanitized message.
|
||||
/// MCP clients (identified by <c>X-Client-Source: mcp</c>) additionally receive the failing
|
||||
/// operation and a recovery hint so the AI can self-correct. Stack traces, CLR type names and
|
||||
/// infrastructure secrets are never exposed — the correlation id is the handle for the log.
|
||||
/// </summary>
|
||||
public static async Task<HttpResponseData> InternalServerErrorAsync(
|
||||
public static Task<HttpResponseData> InternalServerErrorAsync(
|
||||
this HttpRequestData req,
|
||||
ILogger logger,
|
||||
Exception ex,
|
||||
string operation,
|
||||
object? context = null)
|
||||
string operation)
|
||||
{
|
||||
var correlationId = req.FunctionContext.GetCorrelationId();
|
||||
logger.LogError(ex, "{Operation} failed [CorrelationId={CorrelationId}]", operation, correlationId);
|
||||
|
||||
var isMcpClient = IsMcpRequest(req);
|
||||
var body = ApiErrorWriter.Build(
|
||||
correlationId,
|
||||
Constants.ApiErrorCodes.InternalError,
|
||||
SanitizeErrorMessage(ex, operation),
|
||||
hint: isMcpClient ? GetRecoveryHint(ex) : null,
|
||||
operation: isMcpClient ? operation : null);
|
||||
|
||||
var errorBody = new Dictionary<string, object?>
|
||||
{
|
||||
["error"] = SanitizeErrorMessage(ex, operation),
|
||||
["correlationId"] = correlationId,
|
||||
["exceptionType"] = ex.GetType().Name,
|
||||
};
|
||||
|
||||
// MCP clients get richer details to help the AI self-correct
|
||||
if (isMcpClient)
|
||||
{
|
||||
errorBody["operation"] = operation;
|
||||
if (context != null) errorBody["context"] = context;
|
||||
var hint = GetRecoveryHint(ex);
|
||||
if (hint != null) errorBody["hint"] = hint;
|
||||
}
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await response.WriteAsJsonAsync(errorBody);
|
||||
return response;
|
||||
return ErrorAsync(req, HttpStatusCode.InternalServerError, body);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 500 Internal Server Error (simple overload for backward compatibility).
|
||||
/// Delegates to the enhanced overload using logMessage as the operation name.
|
||||
/// </summary>
|
||||
public static async Task<HttpResponseData> InternalServerErrorAsync(
|
||||
public static Task<HttpResponseData> InternalServerErrorAsync(
|
||||
this HttpRequestData req,
|
||||
ILogger logger,
|
||||
Exception ex,
|
||||
string logMessage = "Unhandled error")
|
||||
Exception ex)
|
||||
{
|
||||
return await InternalServerErrorAsync(req, logger, ex, operation: logMessage);
|
||||
return InternalServerErrorAsync(req, logger, ex, operation: "Unhandled error");
|
||||
}
|
||||
|
||||
/// <summary>Detect MCP clients via the X-Client-Source header set by the MCP server.</summary>
|
||||
|
||||
@@ -76,13 +76,10 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
|
||||
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning("[Auth Middleware] Blocked unauthenticated request to {Path}", logPath);
|
||||
httpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
|
||||
httpContext.Response.ContentType = "application/json";
|
||||
await httpContext.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
success = false,
|
||||
message = "Authentication required. Please provide a valid JWT token."
|
||||
});
|
||||
await ApiErrorWriter.WriteAsync(
|
||||
httpContext, context.GetCorrelationId(), HttpStatusCode.Unauthorized,
|
||||
Constants.ApiErrorCodes.AuthenticationRequired,
|
||||
"Authentication required. Please provide a valid JWT token.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -232,13 +229,10 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
|
||||
if (!authenticated)
|
||||
{
|
||||
_logger.LogWarning("[Auth Middleware] Blocked request with invalid token to {Path}", logPath);
|
||||
httpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
|
||||
httpContext.Response.ContentType = "application/json";
|
||||
await httpContext.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
success = false,
|
||||
message = "Authentication required. Please provide a valid JWT token."
|
||||
});
|
||||
await ApiErrorWriter.WriteAsync(
|
||||
httpContext, context.GetCorrelationId(), HttpStatusCode.Unauthorized,
|
||||
Constants.ApiErrorCodes.AuthenticationRequired,
|
||||
"Authentication required. Please provide a valid JWT token.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,14 @@ public class CorrelationIdMiddleware : IFunctionsWorkerMiddleware
|
||||
private const string HeaderName = "X-Correlation-ID";
|
||||
private const string ItemsKey = "CorrelationId";
|
||||
|
||||
/// <summary>
|
||||
/// Non-safelisted response headers a browser script may read cross-origin. Platform CORS
|
||||
/// (App Service) answers the preflight but has no setting for this header, so the app names
|
||||
/// them itself; whether the platform layer forwards it is verified after deploy — nothing
|
||||
/// depends on it (the portal mints its own X-Correlation-ID and every error body carries it).
|
||||
/// </summary>
|
||||
internal const string ExposedHeaders = "X-Correlation-ID, Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset";
|
||||
|
||||
// Strict allow-list gate against CRLF / log-forgery / App-Insights row-injection via inbound header.
|
||||
private static readonly Regex CorrelationIdPattern = new("^[A-Za-z0-9_-]{1,128}$", RegexOptions.Compiled);
|
||||
|
||||
@@ -57,6 +65,11 @@ public class CorrelationIdMiddleware : IFunctionsWorkerMiddleware
|
||||
httpContext.Response.Headers[HeaderName] = correlationId;
|
||||
}
|
||||
|
||||
if (httpContext != null && !httpContext.Response.Headers.ContainsKey("Access-Control-Expose-Headers"))
|
||||
{
|
||||
httpContext.Response.Headers["Access-Control-Expose-Headers"] = ExposedHeaders;
|
||||
}
|
||||
|
||||
using (_logger.BeginScope(new Dictionary<string, object> { ["CorrelationId"] = correlationId }))
|
||||
{
|
||||
await next(context);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using Azure;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Middleware;
|
||||
@@ -48,23 +49,14 @@ public class GlobalExceptionMiddleware : IFunctionsWorkerMiddleware
|
||||
httpContext.Request.Headers["X-Client-Source"].FirstOrDefault(),
|
||||
"mcp", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var errorBody = new Dictionary<string, object?>
|
||||
{
|
||||
["error"] = ResponseHelper.SanitizeErrorMessage(ex, functionName),
|
||||
["correlationId"] = correlationId,
|
||||
["exceptionType"] = ex.GetType().Name,
|
||||
};
|
||||
|
||||
if (isMcp)
|
||||
{
|
||||
errorBody["operation"] = functionName;
|
||||
var hint = ResponseHelper.GetRecoveryHint(ex);
|
||||
if (hint != null) errorBody["hint"] = hint;
|
||||
}
|
||||
|
||||
httpContext.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
|
||||
httpContext.Response.ContentType = "application/json";
|
||||
await httpContext.Response.WriteAsJsonAsync(errorBody);
|
||||
// Same envelope as ResponseHelper.InternalServerErrorAsync: MCP clients get the
|
||||
// operation + recovery hint; the CLR exception type never leaves the process.
|
||||
await ApiErrorWriter.WriteAsync(
|
||||
httpContext, correlationId, HttpStatusCode.InternalServerError,
|
||||
Constants.ApiErrorCodes.InternalError,
|
||||
ResponseHelper.SanitizeErrorMessage(ex, functionName),
|
||||
hint: isMcp ? ResponseHelper.GetRecoveryHint(ex) : null,
|
||||
operation: isMcp ? functionName : null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
|
||||
LogBlocked(oid, chargeTenantId, decision);
|
||||
var targetLabel = decision.TargetTenantId != null ? await ResolveTenantLabelAsync(decision.TargetTenantId) : null;
|
||||
await WriteExceededAsync(httpContext, decision, BuildExceededResponse(decision, targetLabel));
|
||||
await WriteExceededAsync(context, httpContext, decision, BuildExceededResponse(decision, targetLabel));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -181,7 +181,7 @@ public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
LogBlocked(oid, $"aggregate({chargeTenantIds.Count})", blocking);
|
||||
// A user-level block names the caller's own plan; "every managed tenant exhausted" names the count.
|
||||
var exhausted = blocking.Level == McpQuotaLevel.Tenant ? excludedTargets.Count : (int?)null;
|
||||
await WriteExceededAsync(httpContext, blocking, BuildExceededResponse(blocking, exhaustedTenantCount: exhausted));
|
||||
await WriteExceededAsync(context, httpContext, blocking, BuildExceededResponse(blocking, exhaustedTenantCount: exhausted));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -304,6 +304,7 @@ public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
|
||||
return new McpQuotaExceededResponse
|
||||
{
|
||||
Error = message,
|
||||
QuotaExceeded = true,
|
||||
Plan = decision.Plan,
|
||||
Scope = decision.Scope,
|
||||
@@ -311,7 +312,6 @@ public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
Limit = decision.ExceededLimit,
|
||||
Used = decision.ExceededUsed,
|
||||
ResetUtc = resetStamp,
|
||||
Message = message,
|
||||
TargetTenantId = targetTenantId,
|
||||
};
|
||||
}
|
||||
@@ -339,13 +339,10 @@ public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
decision.TenantDailyUsed, decision.TenantDailyLimit, decision.TenantMonthlyUsed, decision.TenantMonthlyLimit);
|
||||
}
|
||||
|
||||
private static async Task WriteExceededAsync(HttpContext httpContext, McpQuotaDecision decision, McpQuotaExceededResponse body)
|
||||
private static Task WriteExceededAsync(FunctionContext context, HttpContext httpContext, McpQuotaDecision decision, McpQuotaExceededResponse body)
|
||||
{
|
||||
var retryAfterSeconds = Math.Max(1, (int)(decision.ResetUtc - DateTime.UtcNow).TotalSeconds);
|
||||
httpContext.Response.StatusCode = (int)HttpStatusCode.TooManyRequests;
|
||||
httpContext.Response.Headers["Retry-After"] = retryAfterSeconds.ToString();
|
||||
httpContext.Response.ContentType = "application/json";
|
||||
await httpContext.Response.WriteAsJsonAsync(body);
|
||||
return ApiErrorWriter.WriteAsync(httpContext, context.GetCorrelationId(), HttpStatusCode.TooManyRequests, body, retryAfterSeconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -97,14 +97,11 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[PolicyEnforcement] Service error evaluating policy for {Method} {Path}", logMethod, logPath);
|
||||
httpContext.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable;
|
||||
httpContext.Response.ContentType = "application/json";
|
||||
httpContext.Response.Headers["Retry-After"] = "5";
|
||||
await httpContext.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "ServiceUnavailable",
|
||||
message = "Authorization service temporarily unavailable. Please retry."
|
||||
});
|
||||
await ApiErrorWriter.WriteAsync(
|
||||
httpContext, context.GetCorrelationId(), HttpStatusCode.ServiceUnavailable,
|
||||
Constants.ApiErrorCodes.ServiceUnavailable,
|
||||
"Authorization service temporarily unavailable. Please retry.",
|
||||
retryAfterSeconds: 5);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -124,9 +121,11 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
logMethod, logPath, result.StatusCode, LogSanitizer.Clean(result.UserIdentifier), result.UserRole, result.LogReason,
|
||||
LogSanitizer.Clean(principal?.GetObjectId()), LogSanitizer.Clean(principal?.GetTenantId()), clientSource, mcpToolName);
|
||||
await ReportPrivilegedDenialAsync(context, result, httpMethod, requestPath, principal, clientSource, mcpToolName);
|
||||
httpContext.Response.StatusCode = result.StatusCode;
|
||||
httpContext.Response.ContentType = "application/json";
|
||||
await httpContext.Response.WriteAsJsonAsync(new { error = result.ErrorCode, message = result.ErrorMessage });
|
||||
// Envelope: the human message is `error`, the policy code (Forbidden, CrossTenantAccessDenied,
|
||||
// TenantSuspended, AuthenticationRequired, InsufficientPermissions) is `code`.
|
||||
await ApiErrorWriter.WriteAsync(
|
||||
httpContext, context.GetCorrelationId(), (HttpStatusCode)result.StatusCode,
|
||||
result.ErrorCode ?? Constants.ApiErrorCodes.Forbidden, result.ErrorMessage ?? "Access denied.");
|
||||
}
|
||||
|
||||
private static string? DenialHeader(HttpContext httpContext, string name)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Middleware;
|
||||
@@ -139,22 +140,11 @@ public class UserRateLimitMiddleware : IFunctionsWorkerMiddleware
|
||||
"[UserRateLimit] THROTTLED caller={Caller} requests={Count}/{Max} retryAfter={RetryAfter}s",
|
||||
requestContext.CallerId, result.RequestsInWindow, result.MaxRequests, retryAfterSeconds);
|
||||
|
||||
httpContext.Response.StatusCode = (int)HttpStatusCode.TooManyRequests;
|
||||
httpContext.Response.Headers["Retry-After"] = retryAfterSeconds.ToString();
|
||||
httpContext.Response.ContentType = "application/json";
|
||||
|
||||
await httpContext.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
success = false,
|
||||
message = $"Rate limit exceeded: {result.MaxRequests} requests per minute",
|
||||
rateLimitExceeded = true,
|
||||
rateLimitInfo = new
|
||||
{
|
||||
requestsInWindow = result.RequestsInWindow,
|
||||
maxRequests = result.MaxRequests,
|
||||
windowDurationSeconds = result.WindowDuration.TotalSeconds,
|
||||
retryAfterSeconds
|
||||
}
|
||||
});
|
||||
// Envelope with the retry window; the counters ride in the X-RateLimit-* headers above.
|
||||
await ApiErrorWriter.WriteAsync(
|
||||
httpContext, context.GetCorrelationId(), HttpStatusCode.TooManyRequests,
|
||||
Constants.ApiErrorCodes.RateLimited,
|
||||
$"Rate limit exceeded: {result.MaxRequests} requests per minute",
|
||||
retryAfterSeconds: retryAfterSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,6 +279,22 @@ export interface AnalyzeRuleListResponse {
|
||||
rules: AnalyzeRule[];
|
||||
}
|
||||
|
||||
/** The one generic error body: { error, code, correlationId, hint?, retryAfterSeconds?, operation? }. Written by ResponseHelper.ErrorAsync (functions) and ApiErrorWriter (middleware). */
|
||||
export interface ApiErrorResponse {
|
||||
/** Human-readable message. Never carries stack traces or infrastructure detail. */
|
||||
error: string;
|
||||
/** Machine-readable code — Constants.ApiErrorCodes unless a domain class owns it. */
|
||||
code: string;
|
||||
/** The request's correlation id (also the X-Correlation-ID response header); the handle for backend log lookup. */
|
||||
correlationId: string;
|
||||
/** Recovery hint for the caller (MCP clients read it into the tool error text); absent when none applies. */
|
||||
hint?: string;
|
||||
/** Mirrors the Retry-After header on 429/503; absent otherwise. */
|
||||
retryAfterSeconds?: number;
|
||||
/** The failing operation (function name) — 500 bodies for MCP clients only; absent otherwise. */
|
||||
operation?: string;
|
||||
}
|
||||
|
||||
/** One of the top 5 failure codes of the analytics window. */
|
||||
export interface AppAnalyticsFailureCode {
|
||||
code: string;
|
||||
@@ -943,6 +959,7 @@ export interface DelegatedSlotHold {
|
||||
export interface DelegatedSlotLimitReachedResponse {
|
||||
error: string;
|
||||
code: string;
|
||||
correlationId: string;
|
||||
/** The managing (home) tenant whose slots are exhausted. */
|
||||
homeTenantId: string;
|
||||
/** Its display name; absent when the config row carries none. */
|
||||
@@ -2410,8 +2427,12 @@ export interface McpOrganizationUsageItem {
|
||||
lastRequestAt?: string;
|
||||
}
|
||||
|
||||
/** 429 body written by McpQuotaEnforcementMiddleware when the per-user MCP daily/monthly quota is exhausted (structurally a success shape: first key is quotaExceeded). */
|
||||
/** 429 body written by McpQuotaEnforcementMiddleware when the per-user MCP daily/monthly quota is exhausted. Carries the error-envelope prefix (error, code=QuotaExceeded, correlationId); quotaExceeded is the discriminator the MCP error handler keys on. */
|
||||
export interface McpQuotaExceededResponse {
|
||||
/** The full quota message (whose window, which plan, when it resets). */
|
||||
error: string;
|
||||
code: string;
|
||||
correlationId: string;
|
||||
quotaExceeded: boolean;
|
||||
plan: string;
|
||||
/** Which window was exceeded ("daily"/"monthly") — always set on the blocked path. */
|
||||
@@ -2424,7 +2445,6 @@ export interface McpQuotaExceededResponse {
|
||||
used: number;
|
||||
/** Reset time of the exceeded window, pre-formatted "yyyy-MM-ddTHH:mm:ssZ". */
|
||||
resetUtc: string;
|
||||
message: string;
|
||||
/** The MANAGED tenant whose organization windows blocked a delegated (MSP) read — its plan governs the budget, not the caller's. Absent when the caller's own tenant/plan was exceeded and on the all-managed-tenants-exhausted aggregate block. */
|
||||
targetTenantId?: string;
|
||||
}
|
||||
@@ -3207,7 +3227,7 @@ export interface SessionDeletionProgressWire {
|
||||
lastResidualSampleJson?: string;
|
||||
}
|
||||
|
||||
/** Success body (202 Accepted) of the V2 cascade-delete enqueue — the non-success arms stay anonymous error bodies by design (one shape: success=false + message). */
|
||||
/** Success body (202 Accepted) of the V2 cascade-delete enqueue; the rejected arms are SessionDeletionRejectedResponse. */
|
||||
export interface SessionDeletionQueuedResponse {
|
||||
success: boolean;
|
||||
/** Always "queued". */
|
||||
@@ -3216,6 +3236,18 @@ export interface SessionDeletionQueuedResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Error body of DELETE sessions/{id} when the cascade could not be enqueued (409 lock states, 503 kill-switch / CAS exhaustion, 404). Error-envelope prefix plus the lock diagnostics the portal renders; manifestId is absent when no manifest exists (kill-switch refusal). */
|
||||
export interface SessionDeletionRejectedResponse {
|
||||
error: string;
|
||||
/** Constants.ApiErrorCodes: CascadeAlreadyInFlight, CascadePoisonedUseRestore, KillSwitchActive, CasExhaustedRetryLater, NotFound, InternalError. */
|
||||
code: string;
|
||||
correlationId: string;
|
||||
/** The in-flight cascade's state on the 409 arms; absent otherwise. */
|
||||
deletionState?: string;
|
||||
/** The in-flight cascade's manifest on the 409 arms; absent otherwise. */
|
||||
manifestId?: string;
|
||||
}
|
||||
|
||||
/** Paged session listing envelope shared by GetSessions, GetAllSessions, SearchSessionsByCve and SearchSessionsByEvent (identical wire shape). */
|
||||
export interface SessionListResponse {
|
||||
success: boolean;
|
||||
|
||||
@@ -161,3 +161,44 @@ export const LOG_SOURCES = [
|
||||
"mcp",
|
||||
] as const;
|
||||
export type LogSourceName = (typeof LOG_SOURCES)[number];
|
||||
|
||||
/** Every machine-readable `code` an error envelope can carry (C# Constants.ApiErrorCodes + DelegationCodes + DelegatedSlots). */
|
||||
export const API_ERROR_CODES = [
|
||||
"BadRequest",
|
||||
"Unauthorized",
|
||||
"Forbidden",
|
||||
"NotFound",
|
||||
"Conflict",
|
||||
"Gone",
|
||||
"UnprocessableEntity",
|
||||
"RateLimited",
|
||||
"InternalError",
|
||||
"ServiceUnavailable",
|
||||
"AuthenticationRequired",
|
||||
"InsufficientPermissions",
|
||||
"CrossTenantAccessDenied",
|
||||
"TenantSuspended",
|
||||
"QuotaExceeded",
|
||||
"HomeTenantUnresolved",
|
||||
"InvalidTarget",
|
||||
"TenantNotFound",
|
||||
"CascadeAlreadyInFlight",
|
||||
"CascadePoisonedUseRestore",
|
||||
"KillSwitchActive",
|
||||
"CasExhaustedRetryLater",
|
||||
"InvalidInvitation",
|
||||
"InvitationNotFound",
|
||||
"InvitationExpired",
|
||||
"InvitationAlreadyUsed",
|
||||
"InvitationCancelled",
|
||||
"CannotAcceptOwnInvitation",
|
||||
"AlreadyManaged",
|
||||
"ManagerNotEntitled",
|
||||
"DelegatedAdminNotAllowed",
|
||||
"NotManagedBySelfService",
|
||||
"NotATenantMember",
|
||||
"AssigneeNotFound",
|
||||
"IdentityBindingConflict",
|
||||
"DelegatedSlotLimitReached",
|
||||
] as const;
|
||||
export type ApiErrorCode = (typeof API_ERROR_CODES)[number];
|
||||
|
||||
@@ -266,6 +266,50 @@ namespace AutopilotMonitor.Shared
|
||||
public const string SessionOwnerMismatch = "session_owner_mismatch";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Machine-readable <c>code</c> values of the typed error envelope (<c>ApiErrorResponse</c>).
|
||||
/// Status defaults first (one per HTTP status the API emits), then the codes a consumer
|
||||
/// branches on. Domain code classes (<see cref="DelegationCodes"/>, <see cref="DelegatedSlots"/>)
|
||||
/// keep their own values; the manifest exports all of them together as <c>apiErrorCodes</c>.
|
||||
/// Wire contract — keep stable, PascalCase.
|
||||
/// </summary>
|
||||
public static class ApiErrorCodes
|
||||
{
|
||||
// ── status defaults ──
|
||||
public const string BadRequest = "BadRequest";
|
||||
public const string Unauthorized = "Unauthorized";
|
||||
public const string Forbidden = "Forbidden";
|
||||
public const string NotFound = "NotFound";
|
||||
public const string Conflict = "Conflict";
|
||||
public const string Gone = "Gone";
|
||||
public const string UnprocessableEntity = "UnprocessableEntity";
|
||||
public const string RateLimited = "RateLimited";
|
||||
public const string InternalError = "InternalError";
|
||||
public const string ServiceUnavailable = "ServiceUnavailable";
|
||||
|
||||
// ── policy / authentication (PolicyEnforcementMiddleware, AuthenticationMiddleware) ──
|
||||
public const string AuthenticationRequired = "AuthenticationRequired";
|
||||
public const string InsufficientPermissions = "InsufficientPermissions";
|
||||
public const string CrossTenantAccessDenied = "CrossTenantAccessDenied";
|
||||
public const string TenantSuspended = "TenantSuspended";
|
||||
|
||||
// ── MCP quota (McpQuotaEnforcementMiddleware) ──
|
||||
public const string QuotaExceeded = "QuotaExceeded";
|
||||
|
||||
// ── identity binding (grant endpoints, HTTP 422) ──
|
||||
public const string HomeTenantUnresolved = "HomeTenantUnresolved";
|
||||
|
||||
// ── app homing (AppHomingFunction) ──
|
||||
public const string InvalidTarget = "InvalidTarget";
|
||||
public const string TenantNotFound = "TenantNotFound";
|
||||
|
||||
// ── session deletion cascade (DeleteSessionFunction) ──
|
||||
public const string CascadeAlreadyInFlight = "CascadeAlreadyInFlight";
|
||||
public const string CascadePoisonedUseRestore = "CascadePoisonedUseRestore";
|
||||
public const string KillSwitchActive = "KillSwitchActive";
|
||||
public const string CasExhaustedRetryLater = "CasExhaustedRetryLater";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// API endpoint paths used by the agent
|
||||
/// </summary>
|
||||
|
||||
@@ -32,6 +32,41 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public bool Success { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marker for every typed HTTP ERROR body (any non-2xx response). The wire prefix is fixed:
|
||||
/// the first three keys are <c>error</c> (human-readable), <c>code</c> (machine-readable,
|
||||
/// <c>Constants.ApiErrorCodes</c> or a domain code class) and <c>correlationId</c> (the
|
||||
/// request's X-Correlation-ID, stamped by the writer — never by the call site). Specialised
|
||||
/// implementers may append domain fields after the prefix; TypedResponseGuardTests pins the
|
||||
/// prefix order by reflection. <see cref="ApiErrorResponse"/> is the generic implementer.
|
||||
/// </summary>
|
||||
public interface IApiErrorResponse : IApiResponse
|
||||
{
|
||||
string Error { get; }
|
||||
string Code { get; }
|
||||
string CorrelationId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one generic error body: <c>{ error, code, correlationId, hint?, retryAfterSeconds?, operation? }</c>.
|
||||
/// Written by <c>ResponseHelper.ErrorAsync</c> (functions) and <c>ApiErrorWriter</c> (middleware).
|
||||
/// </summary>
|
||||
public class ApiErrorResponse : IApiErrorResponse
|
||||
{
|
||||
/// <summary>Human-readable message. Never carries stack traces or infrastructure detail.</summary>
|
||||
public string Error { get; set; } = default!;
|
||||
/// <summary>Machine-readable code — <c>Constants.ApiErrorCodes</c> unless a domain class owns it.</summary>
|
||||
public string Code { get; set; } = default!;
|
||||
/// <summary>The request's correlation id (also the X-Correlation-ID response header); the handle for backend log lookup.</summary>
|
||||
public string CorrelationId { get; set; } = string.Empty;
|
||||
/// <summary>Recovery hint for the caller (MCP clients read it into the tool error text); absent when none applies.</summary>
|
||||
public string? Hint { get; set; }
|
||||
/// <summary>Mirrors the Retry-After header on 429/503; absent otherwise.</summary>
|
||||
public int? RetryAfterSeconds { get; set; }
|
||||
/// <summary>The failing operation (function name) — 500 bodies for MCP clients only; absent otherwise.</summary>
|
||||
public string? Operation { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a list property whose items are runtime projections of <see cref="ItemType"/>
|
||||
/// (a <c>fields=</c> query-parameter projection builds dictionaries with a subset of the
|
||||
|
||||
@@ -12,10 +12,11 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// <c>error</c> comes first so generic error rendering keeps working; <c>code</c> lets the GA UI offer the
|
||||
/// "raise the limit and retry" flow.
|
||||
/// </summary>
|
||||
public class DelegatedSlotLimitReachedResponse : IApiResponse
|
||||
public class DelegatedSlotLimitReachedResponse : IApiErrorResponse
|
||||
{
|
||||
public string Error { get; set; } = default!;
|
||||
public string Code { get; set; } = Constants.DelegatedSlots.LimitReachedCode;
|
||||
public string CorrelationId { get; set; } = string.Empty;
|
||||
/// <summary>The managing (home) tenant whose slots are exhausted.</summary>
|
||||
public string HomeTenantId { get; set; } = default!;
|
||||
/// <summary>Its display name; absent when the config row carries none.</summary>
|
||||
|
||||
@@ -213,11 +213,16 @@ namespace AutopilotMonitor.Shared.Models
|
||||
|
||||
/// <summary>
|
||||
/// 429 body written by McpQuotaEnforcementMiddleware when the per-user MCP daily/monthly
|
||||
/// quota is exhausted (structurally a success shape: first key is quotaExceeded).
|
||||
/// quota is exhausted. Carries the error-envelope prefix (error, code=QuotaExceeded,
|
||||
/// correlationId); <c>quotaExceeded</c> is the discriminator the MCP error handler keys on.
|
||||
/// </summary>
|
||||
// Declaration order == wire order.
|
||||
public class McpQuotaExceededResponse : IApiResponse
|
||||
public class McpQuotaExceededResponse : IApiErrorResponse
|
||||
{
|
||||
/// <summary>The full quota message (whose window, which plan, when it resets).</summary>
|
||||
public string Error { get; set; } = default!;
|
||||
public string Code { get; set; } = Constants.ApiErrorCodes.QuotaExceeded;
|
||||
public string CorrelationId { get; set; } = string.Empty;
|
||||
public bool QuotaExceeded { get; set; }
|
||||
public string Plan { get; set; } = default!;
|
||||
|
||||
@@ -236,8 +241,6 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// <summary>Reset time of the exceeded window, pre-formatted "yyyy-MM-ddTHH:mm:ssZ".</summary>
|
||||
public string ResetUtc { get; set; } = default!;
|
||||
|
||||
public string Message { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// The MANAGED tenant whose organization windows blocked a delegated (MSP) read — its plan governs
|
||||
/// the budget, not the caller's. Absent when the caller's own tenant/plan was exceeded and on the
|
||||
|
||||
@@ -29,8 +29,8 @@ namespace AutopilotMonitor.Shared.Models
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Success body (202 Accepted) of the V2 cascade-delete enqueue — the non-success arms
|
||||
/// stay anonymous error bodies by design (one shape: success=false + message).
|
||||
/// Success body (202 Accepted) of the V2 cascade-delete enqueue; the rejected arms are
|
||||
/// <see cref="SessionDeletionRejectedResponse"/>.
|
||||
/// </summary>
|
||||
public class SessionDeletionQueuedResponse : IApiResponse
|
||||
{
|
||||
@@ -40,4 +40,21 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public string? ManifestId { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error body of DELETE sessions/{id} when the cascade could not be enqueued (409 lock states,
|
||||
/// 503 kill-switch / CAS exhaustion, 404). Error-envelope prefix plus the lock diagnostics the
|
||||
/// portal renders; <c>manifestId</c> is absent when no manifest exists (kill-switch refusal).
|
||||
/// </summary>
|
||||
public class SessionDeletionRejectedResponse : IApiErrorResponse
|
||||
{
|
||||
public string Error { get; set; } = default!;
|
||||
/// <summary>Constants.ApiErrorCodes: CascadeAlreadyInFlight, CascadePoisonedUseRestore, KillSwitchActive, CasExhaustedRetryLater, NotFound, InternalError.</summary>
|
||||
public string Code { get; set; } = default!;
|
||||
public string CorrelationId { get; set; } = string.Empty;
|
||||
/// <summary>The in-flight cascade's state on the 409 arms; absent otherwise.</summary>
|
||||
public string? DeletionState { get; set; }
|
||||
/// <summary>The in-flight cascade's manifest on the 409 arms; absent otherwise.</summary>
|
||||
public string? ManifestId { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,8 @@ const MCP_VOCABULARIES = [
|
||||
doc: "Session-annotation verdicts (C# AnnotationVerdicts)." },
|
||||
{ section: "logSources", constName: "LOG_SOURCES", typeName: "LogSourceName",
|
||||
doc: "Telemetry stores behind the operator KQL proxy — query_backend_logs `source` (C# LogQuerySources)." },
|
||||
{ section: "apiErrorCodes", constName: "API_ERROR_CODES", typeName: "ApiErrorCode",
|
||||
doc: "Every machine-readable `code` an error envelope can carry (C# Constants.ApiErrorCodes + DelegationCodes + DelegatedSlots)." },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -317,6 +317,44 @@ export const SHARED_MANIFEST = {
|
||||
"web",
|
||||
"mcp"
|
||||
],
|
||||
"apiErrorCodes": [
|
||||
"BadRequest",
|
||||
"Unauthorized",
|
||||
"Forbidden",
|
||||
"NotFound",
|
||||
"Conflict",
|
||||
"Gone",
|
||||
"UnprocessableEntity",
|
||||
"RateLimited",
|
||||
"InternalError",
|
||||
"ServiceUnavailable",
|
||||
"AuthenticationRequired",
|
||||
"InsufficientPermissions",
|
||||
"CrossTenantAccessDenied",
|
||||
"TenantSuspended",
|
||||
"QuotaExceeded",
|
||||
"HomeTenantUnresolved",
|
||||
"InvalidTarget",
|
||||
"TenantNotFound",
|
||||
"CascadeAlreadyInFlight",
|
||||
"CascadePoisonedUseRestore",
|
||||
"KillSwitchActive",
|
||||
"CasExhaustedRetryLater",
|
||||
"InvalidInvitation",
|
||||
"InvitationNotFound",
|
||||
"InvitationExpired",
|
||||
"InvitationAlreadyUsed",
|
||||
"InvitationCancelled",
|
||||
"CannotAcceptOwnInvitation",
|
||||
"AlreadyManaged",
|
||||
"ManagerNotEntitled",
|
||||
"DelegatedAdminNotAllowed",
|
||||
"NotManagedBySelfService",
|
||||
"NotATenantMember",
|
||||
"AssigneeNotFound",
|
||||
"IdentityBindingConflict",
|
||||
"DelegatedSlotLimitReached"
|
||||
],
|
||||
"tenantRoles": [
|
||||
"Admin",
|
||||
"Operator",
|
||||
|
||||
@@ -313,6 +313,44 @@
|
||||
"web",
|
||||
"mcp"
|
||||
],
|
||||
"apiErrorCodes": [
|
||||
"BadRequest",
|
||||
"Unauthorized",
|
||||
"Forbidden",
|
||||
"NotFound",
|
||||
"Conflict",
|
||||
"Gone",
|
||||
"UnprocessableEntity",
|
||||
"RateLimited",
|
||||
"InternalError",
|
||||
"ServiceUnavailable",
|
||||
"AuthenticationRequired",
|
||||
"InsufficientPermissions",
|
||||
"CrossTenantAccessDenied",
|
||||
"TenantSuspended",
|
||||
"QuotaExceeded",
|
||||
"HomeTenantUnresolved",
|
||||
"InvalidTarget",
|
||||
"TenantNotFound",
|
||||
"CascadeAlreadyInFlight",
|
||||
"CascadePoisonedUseRestore",
|
||||
"KillSwitchActive",
|
||||
"CasExhaustedRetryLater",
|
||||
"InvalidInvitation",
|
||||
"InvitationNotFound",
|
||||
"InvitationExpired",
|
||||
"InvitationAlreadyUsed",
|
||||
"InvitationCancelled",
|
||||
"CannotAcceptOwnInvitation",
|
||||
"AlreadyManaged",
|
||||
"ManagerNotEntitled",
|
||||
"DelegatedAdminNotAllowed",
|
||||
"NotManagedBySelfService",
|
||||
"NotATenantMember",
|
||||
"AssigneeNotFound",
|
||||
"IdentityBindingConflict",
|
||||
"DelegatedSlotLimitReached"
|
||||
],
|
||||
"tenantRoles": [
|
||||
"Admin",
|
||||
"Operator",
|
||||
@@ -1718,6 +1756,63 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"ApiErrorResponse": {
|
||||
"kind": "object",
|
||||
"doc": "The one generic error body: { error, code, correlationId, hint?, retryAfterSeconds?, operation? }. Written by ResponseHelper.ErrorAsync (functions) and ApiErrorWriter (middleware).",
|
||||
"fields": [
|
||||
{
|
||||
"name": "error",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"doc": "Human-readable message. Never carries stack traces or infrastructure detail."
|
||||
},
|
||||
{
|
||||
"name": "code",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"doc": "Machine-readable code — Constants.ApiErrorCodes unless a domain class owns it."
|
||||
},
|
||||
{
|
||||
"name": "correlationId",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"doc": "The request's correlation id (also the X-Correlation-ID response header); the handle for backend log lookup."
|
||||
},
|
||||
{
|
||||
"name": "hint",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "Recovery hint for the caller (MCP clients read it into the tool error text); absent when none applies."
|
||||
},
|
||||
{
|
||||
"name": "retryAfterSeconds",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "Mirrors the Retry-After header on 429/503; absent otherwise."
|
||||
},
|
||||
{
|
||||
"name": "operation",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "The failing operation (function name) — 500 bodies for MCP clients only; absent otherwise."
|
||||
}
|
||||
]
|
||||
},
|
||||
"AppAnalyticsFailureCode": {
|
||||
"kind": "object",
|
||||
"doc": "One of the top 5 failure codes of the analytics window.",
|
||||
@@ -4781,6 +4876,13 @@
|
||||
"name": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "correlationId",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "homeTenantId",
|
||||
"type": {
|
||||
@@ -11377,8 +11479,30 @@
|
||||
},
|
||||
"McpQuotaExceededResponse": {
|
||||
"kind": "object",
|
||||
"doc": "429 body written by McpQuotaEnforcementMiddleware when the per-user MCP daily/monthly quota is exhausted (structurally a success shape: first key is quotaExceeded).",
|
||||
"doc": "429 body written by McpQuotaEnforcementMiddleware when the per-user MCP daily/monthly quota is exhausted. Carries the error-envelope prefix (error, code=QuotaExceeded, correlationId); quotaExceeded is the discriminator the MCP error handler keys on.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "error",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"doc": "The full quota message (whose window, which plan, when it resets)."
|
||||
},
|
||||
{
|
||||
"name": "code",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "correlationId",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "quotaExceeded",
|
||||
"type": {
|
||||
@@ -11434,13 +11558,6 @@
|
||||
},
|
||||
"doc": "Reset time of the exceeded window, pre-formatted \"yyyy-MM-ddTHH:mm:ssZ\"."
|
||||
},
|
||||
{
|
||||
"name": "message",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "targetTenantId",
|
||||
"type": {
|
||||
@@ -14944,7 +15061,7 @@
|
||||
},
|
||||
"SessionDeletionQueuedResponse": {
|
||||
"kind": "object",
|
||||
"doc": "Success body (202 Accepted) of the V2 cascade-delete enqueue — the non-success arms stay anonymous error bodies by design (one shape: success=false + message).",
|
||||
"doc": "Success body (202 Accepted) of the V2 cascade-delete enqueue; the rejected arms are SessionDeletionRejectedResponse.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "success",
|
||||
@@ -14978,6 +15095,52 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"SessionDeletionRejectedResponse": {
|
||||
"kind": "object",
|
||||
"doc": "Error body of DELETE sessions/{id} when the cascade could not be enqueued (409 lock states, 503 kill-switch / CAS exhaustion, 404). Error-envelope prefix plus the lock diagnostics the portal renders; manifestId is absent when no manifest exists (kill-switch refusal).",
|
||||
"fields": [
|
||||
{
|
||||
"name": "error",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "code",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"doc": "Constants.ApiErrorCodes: CascadeAlreadyInFlight, CascadePoisonedUseRestore, KillSwitchActive, CasExhaustedRetryLater, NotFound, InternalError."
|
||||
},
|
||||
{
|
||||
"name": "correlationId",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "deletionState",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "The in-flight cascade's state on the 409 arms; absent otherwise."
|
||||
},
|
||||
{
|
||||
"name": "manifestId",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "The in-flight cascade's manifest on the 409 arms; absent otherwise."
|
||||
}
|
||||
]
|
||||
},
|
||||
"SessionListResponse": {
|
||||
"kind": "object",
|
||||
"doc": "Paged session listing envelope shared by GetSessions, GetAllSessions, SearchSessionsByCve and SearchSessionsByEvent (identical wire shape).",
|
||||
|
||||
@@ -279,6 +279,22 @@ export interface AnalyzeRuleListResponse {
|
||||
rules: AnalyzeRule[];
|
||||
}
|
||||
|
||||
/** The one generic error body: { error, code, correlationId, hint?, retryAfterSeconds?, operation? }. Written by ResponseHelper.ErrorAsync (functions) and ApiErrorWriter (middleware). */
|
||||
export interface ApiErrorResponse {
|
||||
/** Human-readable message. Never carries stack traces or infrastructure detail. */
|
||||
error: string;
|
||||
/** Machine-readable code — Constants.ApiErrorCodes unless a domain class owns it. */
|
||||
code: string;
|
||||
/** The request's correlation id (also the X-Correlation-ID response header); the handle for backend log lookup. */
|
||||
correlationId: string;
|
||||
/** Recovery hint for the caller (MCP clients read it into the tool error text); absent when none applies. */
|
||||
hint?: string;
|
||||
/** Mirrors the Retry-After header on 429/503; absent otherwise. */
|
||||
retryAfterSeconds?: number;
|
||||
/** The failing operation (function name) — 500 bodies for MCP clients only; absent otherwise. */
|
||||
operation?: string;
|
||||
}
|
||||
|
||||
/** One of the top 5 failure codes of the analytics window. */
|
||||
export interface AppAnalyticsFailureCode {
|
||||
code: string;
|
||||
@@ -943,6 +959,7 @@ export interface DelegatedSlotHold {
|
||||
export interface DelegatedSlotLimitReachedResponse {
|
||||
error: string;
|
||||
code: string;
|
||||
correlationId: string;
|
||||
/** The managing (home) tenant whose slots are exhausted. */
|
||||
homeTenantId: string;
|
||||
/** Its display name; absent when the config row carries none. */
|
||||
@@ -2410,8 +2427,12 @@ export interface McpOrganizationUsageItem {
|
||||
lastRequestAt?: string;
|
||||
}
|
||||
|
||||
/** 429 body written by McpQuotaEnforcementMiddleware when the per-user MCP daily/monthly quota is exhausted (structurally a success shape: first key is quotaExceeded). */
|
||||
/** 429 body written by McpQuotaEnforcementMiddleware when the per-user MCP daily/monthly quota is exhausted. Carries the error-envelope prefix (error, code=QuotaExceeded, correlationId); quotaExceeded is the discriminator the MCP error handler keys on. */
|
||||
export interface McpQuotaExceededResponse {
|
||||
/** The full quota message (whose window, which plan, when it resets). */
|
||||
error: string;
|
||||
code: string;
|
||||
correlationId: string;
|
||||
quotaExceeded: boolean;
|
||||
plan: string;
|
||||
/** Which window was exceeded ("daily"/"monthly") — always set on the blocked path. */
|
||||
@@ -2424,7 +2445,6 @@ export interface McpQuotaExceededResponse {
|
||||
used: number;
|
||||
/** Reset time of the exceeded window, pre-formatted "yyyy-MM-ddTHH:mm:ssZ". */
|
||||
resetUtc: string;
|
||||
message: string;
|
||||
/** The MANAGED tenant whose organization windows blocked a delegated (MSP) read — its plan governs the budget, not the caller's. Absent when the caller's own tenant/plan was exceeded and on the all-managed-tenants-exhausted aggregate block. */
|
||||
targetTenantId?: string;
|
||||
}
|
||||
@@ -3207,7 +3227,7 @@ export interface SessionDeletionProgressWire {
|
||||
lastResidualSampleJson?: string;
|
||||
}
|
||||
|
||||
/** Success body (202 Accepted) of the V2 cascade-delete enqueue — the non-success arms stay anonymous error bodies by design (one shape: success=false + message). */
|
||||
/** Success body (202 Accepted) of the V2 cascade-delete enqueue; the rejected arms are SessionDeletionRejectedResponse. */
|
||||
export interface SessionDeletionQueuedResponse {
|
||||
success: boolean;
|
||||
/** Always "queued". */
|
||||
@@ -3216,6 +3236,18 @@ export interface SessionDeletionQueuedResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Error body of DELETE sessions/{id} when the cascade could not be enqueued (409 lock states, 503 kill-switch / CAS exhaustion, 404). Error-envelope prefix plus the lock diagnostics the portal renders; manifestId is absent when no manifest exists (kill-switch refusal). */
|
||||
export interface SessionDeletionRejectedResponse {
|
||||
error: string;
|
||||
/** Constants.ApiErrorCodes: CascadeAlreadyInFlight, CascadePoisonedUseRestore, KillSwitchActive, CasExhaustedRetryLater, NotFound, InternalError. */
|
||||
code: string;
|
||||
correlationId: string;
|
||||
/** The in-flight cascade's state on the 409 arms; absent otherwise. */
|
||||
deletionState?: string;
|
||||
/** The in-flight cascade's manifest on the 409 arms; absent otherwise. */
|
||||
manifestId?: string;
|
||||
}
|
||||
|
||||
/** Paged session listing envelope shared by GetSessions, GetAllSessions, SearchSessionsByCve and SearchSessionsByEvent (identical wire shape). */
|
||||
export interface SessionListResponse {
|
||||
success: boolean;
|
||||
|
||||
Reference in New Issue
Block a user