feat(inbound-api): machine-readable error codes per spec (incl. SITE_UNREACHABLE) + 415 for non-JSON bodies

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 04:29:55 -04:00
parent 761729b5d0
commit b5e80d4c00
8 changed files with 248 additions and 30 deletions
@@ -51,6 +51,21 @@ public static class EndpointExtensions
/// </summary>
private const string UnauthorizedMessage = "Invalid or missing API key";
/// <summary>
/// C3: builds the canonical inbound-API error body — <c>{ "error": message, "code":
/// code }</c> — at the given HTTP status. Every error return in
/// <see cref="HandleInboundApiRequest"/> routes through this helper so the
/// machine-readable <c>code</c> field (the audience is external integrators) is
/// present and consistent across all failure branches. The <c>error</c> field is
/// unchanged, so callers that only read it are unaffected (additive field).
/// </summary>
/// <param name="code">The machine-readable error code (e.g. <c>UNAUTHORIZED</c>, <c>SITE_UNREACHABLE</c>).</param>
/// <param name="message">The human-readable, caller-safe error message.</param>
/// <param name="status">The HTTP status code for the response.</param>
/// <returns>A JSON result carrying the error + code at the given status.</returns>
private static IResult Error(string code, string message, int status) =>
Results.Json(new { error = message, code }, statusCode: status);
/// <summary>Registers the <c>POST /api/{methodName}</c> inbound API endpoint with the active-node gate and body-size filter applied.</summary>
/// <param name="endpoints">The route builder to add the endpoint to.</param>
/// <returns>The same <paramref name="endpoints"/> instance for chaining.</returns>
@@ -108,9 +123,7 @@ public static class EndpointExtensions
"Inbound API auth failure for method {Method}: {Failure} (status 401)",
methodName, verification.Failure);
return Results.Json(
new { error = UnauthorizedMessage },
statusCode: StatusCodes.Status401Unauthorized);
return Error("UNAUTHORIZED", UnauthorizedMessage, StatusCodes.Status401Unauthorized);
}
var identity = verification.Identity!;
@@ -150,9 +163,7 @@ public static class EndpointExtensions
"Inbound API authz failure for method {Method}: not approved (status 403)",
methodName);
return Results.Json(
new { error = NotApprovedMessage },
statusCode: StatusCodes.Status403Forbidden);
return Error("NOT_APPROVED", NotApprovedMessage, StatusCodes.Status403Forbidden);
}
// Telemetry follow-on: count this inbound request against the resolved,
@@ -168,6 +179,20 @@ public static class EndpointExtensions
httpContext.Items[AuditWriteMiddleware.AuditActorItemKey] =
identity.DisplayName;
// S9: a body sent with an explicit non-JSON Content-Type is a media-type
// mismatch, not malformed JSON — reject it with 415 rather than a misleading
// 400 "invalid JSON". A body with NO Content-Type header is still parsed
// leniently as JSON below (preserving existing callers). The sniff is
// ordinal-ignore-case so "application/JSON" etc. are treated as JSON.
if (httpContext.Request.ContentLength > 0
&& !string.IsNullOrEmpty(httpContext.Request.ContentType)
&& httpContext.Request.ContentType.Contains("json", StringComparison.OrdinalIgnoreCase) == false)
{
return Error(
"UNSUPPORTED_MEDIA_TYPE", "Content-Type must be application/json",
StatusCodes.Status415UnsupportedMediaType);
}
// Deserialize and validate parameters
JsonElement? body = null;
try
@@ -188,9 +213,7 @@ public static class EndpointExtensions
}
catch (JsonException)
{
return Results.Json(
new { error = "Invalid JSON in request body" },
statusCode: 400);
return Error("INVALID_JSON", "Invalid JSON in request body", StatusCodes.Status400BadRequest);
}
// Thread the JSON-Schema $ref resolution seam into parameter
@@ -209,9 +232,9 @@ public static class EndpointExtensions
var paramResult = ParameterValidator.Validate(body, method.ParameterDefinitions, resolveRef);
if (!paramResult.IsValid)
{
return Results.Json(
new { error = paramResult.ErrorMessage },
statusCode: 400);
return Error(
"VALIDATION_FAILED", paramResult.ErrorMessage ?? "Parameter validation failed",
StatusCodes.Status400BadRequest);
}
// Execute the method's script
@@ -250,9 +273,13 @@ public static class EndpointExtensions
"Inbound API script failure for method {Method}: {Error}",
methodName, scriptResult.ErrorMessage);
return Results.Json(
new { error = scriptResult.ErrorMessage ?? "Internal server error" },
statusCode: 500);
// C3: propagate the executor's machine-readable code (TIMEOUT,
// SITE_UNREACHABLE, SCRIPT_COMPILE_FAILED, …); an unclassified failure
// falls back to the SCRIPT_ERROR catch-all.
return Error(
scriptResult.ErrorCode ?? "SCRIPT_ERROR",
scriptResult.ErrorMessage ?? "Internal server error",
StatusCodes.Status500InternalServerError);
}
// Return the script result as JSON