(
+ [AsParameters] ProviderOAuthCallbackQuery query,
OAuthPkceService pkceService,
CancellationToken ct) =>
{
- var code = context.Request.Query["code"].ToString();
- var state = context.Request.Query["state"].ToString();
-
- if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
+ if (string.IsNullOrEmpty(query.Code) || string.IsNullOrEmpty(query.State))
{
- context.Response.ContentType = "text/html";
- await context.Response.WriteAsync(
- "Authorization failed
Missing code or state parameter.
", ct);
- return;
+ return TypedResults.Content(
+ "Authorization failed
Missing code or state parameter.
",
+ "text/html");
}
try
{
- await pkceService.CompleteAuthorizationAsync(code, state, ct);
- context.Response.ContentType = "text/html";
- await context.Response.WriteAsync(
- "Authorization complete
You may close this tab and return to the terminal.
", ct);
+ await pkceService.CompleteAuthorizationAsync(query.Code, query.State, ct);
+ return TypedResults.Content(
+ "Authorization complete
You may close this tab and return to the terminal.
",
+ "text/html");
}
catch (Exception ex)
{
- context.Response.StatusCode = 500;
- context.Response.ContentType = "text/html";
- await context.Response.WriteAsync(
- $"Authorization failed
{System.Net.WebUtility.HtmlEncode(ex.Message)}
", ct);
+ return TypedResults.Content(
+ $"Authorization failed
{System.Net.WebUtility.HtmlEncode(ex.Message)}
",
+ "text/html",
+ contentEncoding: null,
+ statusCode: StatusCodes.Status500InternalServerError);
}
- }).AllowAnonymous();
+ })
+ .WithName("ProviderOAuthCallback")
+ .WithSummary("Browser redirect callback that completes a provider OAuth flow.")
+ .WithTags("Provider OAuth")
+ .AllowAnonymous();
app.MapGet("/api/provider/oauth/status/{state}", (
string state,
@@ -87,15 +118,17 @@ await context.Response.WriteAsync(
var remoteIp = httpContext.Connection.RemoteIpAddress;
var isLoopback = remoteIp is not null && System.Net.IPAddress.IsLoopback(remoteIp);
- return Results.Ok(new
- {
- status = status.ToString(),
- hasToken = result is not null,
- accessToken = isLoopback ? result?.AccessToken.Value : null,
- refreshToken = isLoopback ? result?.RefreshToken?.Value : null,
- expiresAt = result?.ExpiresAt?.ToString("o"),
- });
- }).RequireAuthorization();
+ return TypedResults.Ok(new ProviderOAuthStatusResponse(
+ Status: status.ToString(),
+ HasToken: result is not null,
+ AccessToken: isLoopback ? result?.AccessToken.Value : null,
+ RefreshToken: isLoopback ? result?.RefreshToken?.Value : null,
+ ExpiresAt: result?.ExpiresAt?.ToString("o")));
+ })
+ .WithName("GetProviderOAuthStatus")
+ .WithSummary("Get the status (and, over loopback, tokens) of a provider OAuth flow.")
+ .WithTags("Provider OAuth")
+ .RequireAuthorization();
return app;
}
diff --git a/src/Netclaw.Daemon/Reminders/ReminderEndpointRouteBuilderExtensions.cs b/src/Netclaw.Daemon/Reminders/ReminderEndpointRouteBuilderExtensions.cs
index 461f49d82..2aca87594 100644
--- a/src/Netclaw.Daemon/Reminders/ReminderEndpointRouteBuilderExtensions.cs
+++ b/src/Netclaw.Daemon/Reminders/ReminderEndpointRouteBuilderExtensions.cs
@@ -6,6 +6,7 @@
using Akka.Actor;
using Akka.Hosting;
using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Netclaw.Actors.Channels;
@@ -22,31 +23,32 @@ public static class ReminderEndpointRouteBuilderExtensions
public static IEndpointRouteBuilder MapReminderEndpoints(this IEndpointRouteBuilder app)
{
var reminders = app.MapGroup("/api/reminders")
+ .WithTags("Reminders")
.RequireAuthorization();
- reminders.MapGet("", async (
+ reminders.MapGet("", async ValueTask>> (
IRequiredActor actor,
CancellationToken ct) =>
{
var manager = await actor.GetAsync(ct);
var response = await manager.Ask(
new ListRemindersCommand(IncludeDisabled: false), TimeSpan.FromSeconds(10), ct);
- var projected = response.Reminders.Select(r => new
- {
- id = r.Id.Value,
- title = r.Title,
- enabled = r.Enabled,
- schedule = ListRemindersTool.DescribeSchedule(r.Schedule),
- nextFire = SetReminderTool.FormatTimestamp(r.NextFire),
- expiresAt = r.ExpiresAt is null
+ var projected = response.Reminders.Select(r => new ReminderSummaryDto(
+ Id: r.Id.Value,
+ Title: r.Title,
+ Enabled: r.Enabled,
+ Schedule: ListRemindersTool.DescribeSchedule(r.Schedule),
+ NextFire: SetReminderTool.FormatTimestamp(r.NextFire),
+ ExpiresAt: r.ExpiresAt is null
? null
: SetReminderTool.FormatTimestamp(r.ExpiresAt),
- audience = r.Audience?.ToWireValue(),
- });
- return Results.Ok(projected);
- });
+ Audience: r.Audience?.ToWireValue()));
+ return TypedResults.Ok(projected);
+ })
+ .WithName("ListReminders")
+ .WithSummary("List all active reminders.");
- reminders.MapPost("", async (
+ reminders.MapPost("", async ValueTask, BadRequest, ProblemHttpResult>> (
CreateReminderRequest request,
IRequiredActor actor,
IServiceProvider serviceProvider,
@@ -63,7 +65,7 @@ public static IEndpointRouteBuilder MapReminderEndpoints(this IEndpointRouteBuil
// audience is now required and non-nullable, so a null authorization would otherwise
// be silently defaulted, smuggling the request past the actor's authority check.
if (authorization?.SourceAudience is not { } reminderSourceAudience)
- return Results.Problem(
+ return TypedResults.Problem(
detail: "Creating a reminder requires Operator authority.",
statusCode: StatusCodes.Status403Forbidden);
@@ -102,11 +104,13 @@ public static IEndpointRouteBuilder MapReminderEndpoints(this IEndpointRouteBuil
}, toolContext, ct);
return result.StartsWith("Error", StringComparison.Ordinal)
- ? Results.BadRequest(new { error = result })
- : Results.Ok(new { message = result });
- });
+ ? TypedResults.BadRequest(new ReminderErrorResponse(result))
+ : TypedResults.Ok(new ReminderMessageResponse(result));
+ })
+ .WithName("CreateReminder")
+ .WithSummary("Create a reminder (requires Operator authority).");
- reminders.MapPost("/validate", (
+ reminders.MapPost("/validate", Results, BadRequest> (
CreateReminderRequest request,
TimeProvider timeProvider) =>
{
@@ -116,12 +120,17 @@ public static IEndpointRouteBuilder MapReminderEndpoints(this IEndpointRouteBuil
timeProvider);
if (schedule is null)
- return Results.BadRequest(new { valid = false, error });
+ return TypedResults.BadRequest(new ReminderValidationErrorResponse(Valid: false, Error: error));
- return Results.Ok(new { valid = true, scheduleType = schedule.Type.ToString(), nextFire = schedule.FireAt });
- });
+ return TypedResults.Ok(new ReminderValidationSuccessResponse(
+ Valid: true,
+ ScheduleType: schedule.Type.ToString(),
+ NextFire: schedule.FireAt));
+ })
+ .WithName("ValidateReminderSchedule")
+ .WithSummary("Validate a reminder schedule without persisting it.");
- reminders.MapPost("/import", async (
+ reminders.MapPost("/import", async ValueTask, BadRequest, JsonHttpResult>> (
ImportReminderRequest request,
IRequiredActor actor,
ClaimsPrincipalMapper mapper,
@@ -129,7 +138,7 @@ public static IEndpointRouteBuilder MapReminderEndpoints(this IEndpointRouteBuil
CancellationToken ct) =>
{
if (request.Definition is null)
- return Results.BadRequest(new { error = "Reminder definition is required." });
+ return TypedResults.BadRequest(new ReminderErrorResponse("Reminder definition is required."));
var authorization = ResolveReminderAuthorizationContext(mapper, httpContext);
@@ -142,7 +151,7 @@ public static IEndpointRouteBuilder MapReminderEndpoints(this IEndpointRouteBuil
};
if (mode is null)
- return Results.BadRequest(new { error = "Invalid writeMode. Use create, replace, or upsert." });
+ return TypedResults.BadRequest(new ReminderErrorResponse("Invalid writeMode. Use create, replace, or upsert."));
var manager = await actor.GetAsync(ct);
var response = await manager.Ask(
@@ -158,24 +167,24 @@ public static IEndpointRouteBuilder MapReminderEndpoints(this IEndpointRouteBuil
? StatusCodes.Status404NotFound
: StatusCodes.Status400BadRequest;
- return Results.Json(new
- {
- error = response.ErrorMessage ?? "Import failed.",
- code = response.Error.ToString(),
- id = response.Id.Value
- }, statusCode: status);
+ return TypedResults.Json(
+ new ReminderImportErrorResponse(
+ Error: response.ErrorMessage ?? "Import failed.",
+ Code: response.Error.ToString(),
+ Id: response.Id.Value),
+ statusCode: status);
}
- return Results.Ok(new
- {
- id = response.Id.Value,
- title = response.Title,
- nextFire = response.NextFire,
- message = $"Imported reminder '{response.Id.Value}'."
- });
- });
-
- reminders.MapDelete("/{id}", async (
+ return TypedResults.Ok(new ReminderImportResponse(
+ Id: response.Id.Value,
+ Title: response.Title,
+ NextFire: response.NextFire,
+ Message: $"Imported reminder '{response.Id.Value}'."));
+ })
+ .WithName("ImportReminder")
+ .WithSummary("Import a reminder definition with the requested write mode.");
+
+ reminders.MapDelete("/{id}", async ValueTask, NotFound>> (
string id,
bool? permanent,
IRequiredActor actor,
@@ -191,8 +200,8 @@ public static IEndpointRouteBuilder MapReminderEndpoints(this IEndpointRouteBuil
TimeSpan.FromSeconds(10), ct);
return deleted.Found
- ? Results.Ok(new { message = $"Reminder '{id}' permanently deleted." })
- : Results.NotFound(new { error = $"Reminder '{id}' not found." });
+ ? TypedResults.Ok(new ReminderMessageResponse($"Reminder '{id}' permanently deleted."))
+ : TypedResults.NotFound(new ReminderErrorResponse($"Reminder '{id}' not found."));
}
var response = await manager.Ask(
@@ -200,11 +209,13 @@ public static IEndpointRouteBuilder MapReminderEndpoints(this IEndpointRouteBuil
TimeSpan.FromSeconds(10), ct);
return response.Found
- ? Results.Ok(new { message = $"Reminder '{id}' cancelled (disabled)." })
- : Results.NotFound(new { error = $"Reminder '{id}' not found." });
- });
+ ? TypedResults.Ok(new ReminderMessageResponse($"Reminder '{id}' cancelled (disabled)."))
+ : TypedResults.NotFound(new ReminderErrorResponse($"Reminder '{id}' not found."));
+ })
+ .WithName("DeleteReminder")
+ .WithSummary("Cancel a reminder, or permanently delete it with ?permanent=true.");
- reminders.MapPost("/{id}/disable", async (
+ reminders.MapPost("/{id}/disable", async ValueTask, NotFound>> (
string id,
IRequiredActor actor,
CancellationToken ct) =>
@@ -216,11 +227,13 @@ public static IEndpointRouteBuilder MapReminderEndpoints(this IEndpointRouteBuil
ct);
return !response.Found
- ? Results.NotFound(new { error = response.ErrorMessage ?? $"Reminder '{id}' not found." })
- : Results.Ok(new { id = id, enabled = response.Enabled, message = $"Reminder '{id}' disabled." });
- });
+ ? TypedResults.NotFound(new ReminderErrorResponse(response.ErrorMessage ?? $"Reminder '{id}' not found."))
+ : TypedResults.Ok(new ReminderDisableResponse(id, response.Enabled, $"Reminder '{id}' disabled."));
+ })
+ .WithName("DisableReminder")
+ .WithSummary("Disable a reminder without deleting it.");
- reminders.MapPost("/{id}/enable", async (
+ reminders.MapPost("/{id}/enable", async ValueTask, NotFound, BadRequest>> (
string id,
IRequiredActor actor,
CancellationToken ct) =>
@@ -232,14 +245,16 @@ public static IEndpointRouteBuilder MapReminderEndpoints(this IEndpointRouteBuil
ct);
if (!response.Found)
- return Results.NotFound(new { error = response.ErrorMessage ?? $"Reminder '{id}' not found." });
+ return TypedResults.NotFound(new ReminderErrorResponse(response.ErrorMessage ?? $"Reminder '{id}' not found."));
if (!response.Enabled && !string.IsNullOrWhiteSpace(response.ErrorMessage))
- return Results.BadRequest(new { error = response.ErrorMessage, id, enabled = false });
+ return TypedResults.BadRequest(new ReminderEnableErrorResponse(response.ErrorMessage, id, Enabled: false));
- return Results.Ok(new { id, enabled = response.Enabled, nextFire = response.NextFire, message = $"Reminder '{id}' enabled." });
- });
+ return TypedResults.Ok(new ReminderEnableResponse(id, response.Enabled, response.NextFire, $"Reminder '{id}' enabled."));
+ })
+ .WithName("EnableReminder")
+ .WithSummary("Re-enable a previously disabled reminder.");
- reminders.MapGet("/{id}", async (
+ reminders.MapGet("/{id}", async ValueTask, NotFound>> (
string id,
IRequiredActor actor,
CancellationToken ct) =>
@@ -250,30 +265,30 @@ public static IEndpointRouteBuilder MapReminderEndpoints(this IEndpointRouteBuil
TimeSpan.FromSeconds(10), ct);
if (response.Reminder is null)
- return Results.NotFound(new { error = $"Reminder '{id}' not found." });
+ return TypedResults.NotFound(new ReminderErrorResponse($"Reminder '{id}' not found."));
var r = response.Reminder;
- return Results.Ok(new
- {
- id = r.Id.Value,
- title = r.Title,
- enabled = r.Enabled,
- schedule = ListRemindersTool.DescribeSchedule(r.Schedule),
- nextFire = SetReminderTool.FormatTimestamp(r.NextFire),
- expiresAt = r.ExpiresAt is null
+ return TypedResults.Ok(new ReminderDetailDto(
+ Id: r.Id.Value,
+ Title: r.Title,
+ Enabled: r.Enabled,
+ Schedule: ListRemindersTool.DescribeSchedule(r.Schedule),
+ NextFire: SetReminderTool.FormatTimestamp(r.NextFire),
+ ExpiresAt: r.ExpiresAt is null
? null
: SetReminderTool.FormatTimestamp(r.ExpiresAt),
- instructions = r.Instructions,
- deliveryKind = r.Delivery.Kind.ToString().ToLowerInvariant(),
- deliveryTransport = r.Delivery.Transport,
- deliveryAddress = r.Delivery.Address,
- deliveryRequired = r.DeliveryRequired,
- deliveryInstructions = r.DeliveryInstructions,
- audience = r.Audience?.ToWireValue(),
- });
- });
-
- reminders.MapGet("/{id}/history", async (
+ Instructions: r.Instructions,
+ DeliveryKind: r.Delivery.Kind.ToString().ToLowerInvariant(),
+ DeliveryTransport: r.Delivery.Transport,
+ DeliveryAddress: r.Delivery.Address,
+ DeliveryRequired: r.DeliveryRequired,
+ DeliveryInstructions: r.DeliveryInstructions,
+ Audience: r.Audience?.ToWireValue()));
+ })
+ .WithName("GetReminder")
+ .WithSummary("Get a single reminder's full definition.");
+
+ reminders.MapGet("/{id}/history", async ValueTask>, NotFound>> (
string id,
int? last,
ReminderDefinitionStore definitionStore,
@@ -282,12 +297,14 @@ public static IEndpointRouteBuilder MapReminderEndpoints(this IEndpointRouteBuil
{
var rid = new ReminderId(id);
if (!definitionStore.Exists(rid))
- return Results.NotFound(new { error = $"Reminder '{id}' not found." });
+ return TypedResults.NotFound(new ReminderErrorResponse($"Reminder '{id}' not found."));
var maxRecords = Math.Clamp(last ?? 20, 1, 500);
var records = await historyStore.ReadAsync(rid, maxRecords);
- return Results.Ok(records);
- });
+ return TypedResults.Ok(records);
+ })
+ .WithName("GetReminderHistory")
+ .WithSummary("Get recent fire history for a reminder.");
return app;
}
@@ -336,3 +353,56 @@ internal sealed record ImportReminderRequest
public required ReminderDefinition Definition { get; init; }
public string? WriteMode { get; init; }
}
+
+/// Summary projection of a reminder returned by the list endpoint.
+internal sealed record ReminderSummaryDto(
+ string Id,
+ string Title,
+ bool Enabled,
+ string Schedule,
+ string NextFire,
+ string? ExpiresAt,
+ string? Audience);
+
+/// Full reminder projection returned by GET /api/reminders/{id}.
+internal sealed record ReminderDetailDto(
+ string Id,
+ string Title,
+ bool Enabled,
+ string Schedule,
+ string NextFire,
+ string? ExpiresAt,
+ string Instructions,
+ string DeliveryKind,
+ string? DeliveryTransport,
+ string? DeliveryAddress,
+ bool DeliveryRequired,
+ string? DeliveryInstructions,
+ string? Audience);
+
+/// Acknowledgement carrying a human-readable message.
+internal sealed record ReminderMessageResponse(string Message);
+
+/// Error payload returned when a reminder request fails.
+internal sealed record ReminderErrorResponse(string Error);
+
+/// Successful schedule validation result.
+internal sealed record ReminderValidationSuccessResponse(bool Valid, string ScheduleType, DateTimeOffset? NextFire);
+
+/// Failed schedule validation result.
+internal sealed record ReminderValidationErrorResponse(bool Valid, string? Error);
+
+/// Successful reminder import acknowledgement.
+internal sealed record ReminderImportResponse(string Id, string Title, DateTimeOffset? NextFire, string Message);
+
+/// Failure detail for a rejected reminder import.
+internal sealed record ReminderImportErrorResponse(string Error, string Code, string Id);
+
+/// State of a reminder after a disable request.
+internal sealed record ReminderDisableResponse(string Id, bool Enabled, string Message);
+
+/// State of a reminder after an enable request.
+internal sealed record ReminderEnableResponse(string Id, bool Enabled, DateTimeOffset? NextFire, string Message);
+
+/// Failure detail for a rejected enable request.
+internal sealed record ReminderEnableErrorResponse(string? Error, string Id, bool Enabled);
diff --git a/src/Netclaw.Daemon/Security/PairingEndpointRouteBuilderExtensions.cs b/src/Netclaw.Daemon/Security/PairingEndpointRouteBuilderExtensions.cs
index de34e0758..28f0b1fad 100644
--- a/src/Netclaw.Daemon/Security/PairingEndpointRouteBuilderExtensions.cs
+++ b/src/Netclaw.Daemon/Security/PairingEndpointRouteBuilderExtensions.cs
@@ -6,6 +6,7 @@
using System.Buffers.Text;
using System.Security.Cryptography;
using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Routing;
using Netclaw.Configuration;
@@ -17,7 +18,7 @@ public static IEndpointRouteBuilder MapPairingEndpoints(this IEndpointRouteBuild
{
// Device pairing exchange — unauthenticated, rate-limited, with per-IP lockout guard.
// Accepts a time-limited pairing code and a device name; returns a bearer token on success.
- app.MapPost("/api/pair/exchange", async (
+ app.MapPost("/api/pair/exchange", async ValueTask, BadRequest, NotFound, Conflict, JsonHttpResult>> (
HttpContext httpContext,
PairingCodeExchangeRequest request,
PairingCodeService pairingCodeService,
@@ -33,23 +34,23 @@ public static IEndpointRouteBuilder MapPairingEndpoints(this IEndpointRouteBuild
{
var retryAfter = exchangeGuard.GetRetryAfterSeconds(remoteIp);
httpContext.Response.Headers.RetryAfter = retryAfter?.ToString() ?? "900";
- return Results.Json(
- new { error = "Too many failed attempts. Try again later." },
+ return TypedResults.Json(
+ new PairingErrorResponse("Too many failed attempts. Try again later."),
statusCode: StatusCodes.Status429TooManyRequests);
}
// Layer 2: No-code-pending gate — if no code exists, hide the endpoint entirely.
if (pairingCodeService.GetPendingExpiry() is null)
- return Results.NotFound();
+ return TypedResults.NotFound();
if (string.IsNullOrWhiteSpace(request.Code) || string.IsNullOrWhiteSpace(request.DeviceName))
- return Results.BadRequest(new { error = "code and deviceName are required." });
+ return TypedResults.BadRequest(new PairingErrorResponse("code and deviceName are required."));
if (!pairingCodeService.TryConsume(request.Code))
{
exchangeGuard.RecordFailure(remoteIp);
- return Results.Json(
- new { error = "Invalid, expired, or already-used pairing code." },
+ return TypedResults.Json(
+ new PairingErrorResponse("Invalid, expired, or already-used pairing code."),
statusCode: StatusCodes.Status401Unauthorized);
}
@@ -76,28 +77,40 @@ public static IEndpointRouteBuilder MapPairingEndpoints(this IEndpointRouteBuild
}
catch (InvalidOperationException ex)
{
- return Results.Conflict(new { error = ex.Message });
+ return TypedResults.Conflict(new PairingErrorResponse(ex.Message));
}
- return Results.Ok(new { token = rawToken });
- }).RequireRateLimiting("pairing-exchange").AllowAnonymous();
+ return TypedResults.Ok(new PairingTokenResponse(rawToken));
+ })
+ .WithName("ExchangePairingCode")
+ .WithSummary("Exchange a pairing code for a device bearer token.")
+ .WithTags("Pairing")
+ .RequireRateLimiting("pairing-exchange").AllowAnonymous();
// Device registry management — authenticated (loopback or valid bearer token required).
// Returns a sanitized view of paired devices (no TokenHash/Salt).
- app.MapGet("/api/pair/devices", async (DeviceRegistry deviceRegistry, CancellationToken ct) =>
+ app.MapGet("/api/pair/devices", async ValueTask>> (DeviceRegistry deviceRegistry, CancellationToken ct) =>
{
var devices = await deviceRegistry.ListAsync(ct);
var sanitized = devices.Select(d => new PairedDeviceInfoDto(d.Name, d.CreatedAt, d.LastUsedAt));
- return Results.Ok(sanitized);
- }).RequireAuthorization();
+ return TypedResults.Ok(sanitized);
+ })
+ .WithName("ListPairedDevices")
+ .WithSummary("List paired devices (token material excluded).")
+ .WithTags("Pairing")
+ .RequireAuthorization();
- app.MapDelete("/api/pair/devices/{name}", async (string name, DeviceRegistry deviceRegistry, CancellationToken ct) =>
+ app.MapDelete("/api/pair/devices/{name}", async ValueTask>> (string name, DeviceRegistry deviceRegistry, CancellationToken ct) =>
{
var removed = await deviceRegistry.RemoveAsync(name, ct);
return removed
- ? Results.NoContent()
- : Results.NotFound(new { error = $"Device '{name}' not found." });
- }).RequireAuthorization();
+ ? TypedResults.NoContent()
+ : TypedResults.NotFound(new PairingErrorResponse($"Device '{name}' not found."));
+ })
+ .WithName("RemovePairedDevice")
+ .WithSummary("Remove a paired device by name.")
+ .WithTags("Pairing")
+ .RequireAuthorization();
return app;
}
@@ -107,3 +120,9 @@ public static IEndpointRouteBuilder MapPairingEndpoints(this IEndpointRouteBuild
/// Request body for POST /api/pair/exchange.
///
internal sealed record PairingCodeExchangeRequest(string Code, string DeviceName);
+
+/// Bearer token issued on a successful pairing exchange.
+internal sealed record PairingTokenResponse(string Token);
+
+/// Error payload returned when a pairing request fails.
+internal sealed record PairingErrorResponse(string Error);
diff --git a/src/Netclaw.Daemon/Webhooks/WebhookEndpointRouteBuilderExtensions.cs b/src/Netclaw.Daemon/Webhooks/WebhookEndpointRouteBuilderExtensions.cs
index 20b5cb357..4ef910d1d 100644
--- a/src/Netclaw.Daemon/Webhooks/WebhookEndpointRouteBuilderExtensions.cs
+++ b/src/Netclaw.Daemon/Webhooks/WebhookEndpointRouteBuilderExtensions.cs
@@ -7,6 +7,7 @@
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -23,7 +24,7 @@ public static IEndpointRouteBuilder MapWebhookEndpoints(this IEndpointRouteBuild
.GetRequiredService()
.CreateLogger("Netclaw.Daemon.Webhooks.Endpoint");
- app.MapPost("/api/webhooks/{route}", async (
+ app.MapPost("/api/webhooks/{route}", async ValueTask, UnauthorizedHttpResult, JsonHttpResult, JsonHttpResult>> (
string route,
HttpContext httpContext,
WebhookRouteCatalog routeCatalog,
@@ -36,7 +37,7 @@ public static IEndpointRouteBuilder MapWebhookEndpoints(this IEndpointRouteBuild
CancellationToken ct) =>
{
if (!webhooksConfig.Enabled)
- return Results.NotFound();
+ return TypedResults.NotFound();
var remoteIp = httpContext.Connection.RemoteIpAddress?.ToString();
@@ -46,7 +47,7 @@ public static IEndpointRouteBuilder MapWebhookEndpoints(this IEndpointRouteBuild
logger.LogWarning(
"Webhook rejected route={Route} reason={Reason} remote_ip={RemoteIp} delivery_id={DeliveryId} event_type={EventType}",
route, "route_not_found", remoteIp, (string?)null, (string?)null);
- return Results.NotFound();
+ return TypedResults.NotFound();
}
var bodyRead = await ReadRequestBodyAsync(httpContext.Request, registeredRoute.Config.MaxBodyBytes, ct);
@@ -57,14 +58,14 @@ public static IEndpointRouteBuilder MapWebhookEndpoints(this IEndpointRouteBuild
logger.LogWarning(
"Webhook rejected route={Route} reason={Reason} remote_ip={RemoteIp} delivery_id={DeliveryId} event_type={EventType}",
registeredRoute.Name, "body_too_large", remoteIp, (string?)null, (string?)null);
- return Results.StatusCode(StatusCodes.Status413PayloadTooLarge);
+ return TypedResults.StatusCode(StatusCodes.Status413PayloadTooLarge);
case WebhookBodyReadStatus.InvalidJson:
WebhookTelemetry.RecordInvalidJson(registeredRoute.Name);
logger.LogWarning(
"Webhook rejected route={Route} reason={Reason} remote_ip={RemoteIp} delivery_id={DeliveryId} event_type={EventType}",
registeredRoute.Name, "invalid_json", remoteIp, (string?)null, (string?)null);
- return Results.BadRequest(new { error = "Invalid JSON request body." });
+ return TypedResults.BadRequest(new WebhookErrorResponse("Invalid JSON request body."));
}
var verification = verifier.Verify(registeredRoute, httpContext.Request.Headers, bodyRead.BodyBytes!);
@@ -74,7 +75,7 @@ public static IEndpointRouteBuilder MapWebhookEndpoints(this IEndpointRouteBuild
logger.LogWarning(
"Webhook rejected route={Route} reason={Reason} remote_ip={RemoteIp} delivery_id={DeliveryId} event_type={EventType}",
registeredRoute.Name, "verification_failed", remoteIp, verification.DeliveryId, verification.EventType);
- return Results.Unauthorized();
+ return TypedResults.Unauthorized();
}
if (!registeredRoute.IsEventAllowed(verification.EventType))
@@ -83,8 +84,8 @@ public static IEndpointRouteBuilder MapWebhookEndpoints(this IEndpointRouteBuild
logger.LogDebug(
"Webhook filtered route={Route} reason={Reason} remote_ip={RemoteIp} delivery_id={DeliveryId} event_type={EventType}",
registeredRoute.Name, "event_filtered", remoteIp, verification.DeliveryId, verification.EventType);
- return Results.Json(
- new { status = "ignored", reason = "event_filtered" },
+ return TypedResults.Json(
+ new WebhookIgnoredResponse("ignored", "event_filtered"),
statusCode: StatusCodes.Status202Accepted);
}
@@ -99,8 +100,8 @@ public static IEndpointRouteBuilder MapWebhookEndpoints(this IEndpointRouteBuild
logger.LogDebug(
"Webhook filtered route={Route} reason={Reason} remote_ip={RemoteIp} delivery_id={DeliveryId} event_type={EventType}",
registeredRoute.Name, "duplicate_delivery", remoteIp, verification.DeliveryId, verification.EventType);
- return Results.Json(
- new { status = "ignored", reason = "duplicate_delivery" },
+ return TypedResults.Json(
+ new WebhookIgnoredResponse("ignored", "duplicate_delivery"),
statusCode: StatusCodes.Status202Accepted);
}
@@ -114,7 +115,7 @@ public static IEndpointRouteBuilder MapWebhookEndpoints(this IEndpointRouteBuild
if (guardDecision.RetryAfterSeconds is { } retryAfter)
httpContext.Response.Headers.RetryAfter = retryAfter.ToString();
- return Results.StatusCode(StatusCodes.Status429TooManyRequests);
+ return TypedResults.StatusCode(StatusCodes.Status429TooManyRequests);
}
var now = timeProvider.GetUtcNow();
@@ -152,17 +153,19 @@ public static IEndpointRouteBuilder MapWebhookEndpoints(this IEndpointRouteBuild
}));
executionService.StartInvocation(invocation);
- return Results.Json(
- new
- {
- status = "accepted",
- route = registeredRoute.Name,
- eventType = verification.EventType,
- deliveryId = verification.DeliveryId,
- sessionId = sessionId.Value,
- },
+ return TypedResults.Json(
+ new WebhookAcceptedResponse(
+ Status: "accepted",
+ Route: registeredRoute.Name,
+ EventType: verification.EventType,
+ DeliveryId: verification.DeliveryId,
+ SessionId: sessionId.Value),
statusCode: StatusCodes.Status202Accepted);
- }).AllowAnonymous();
+ })
+ .WithName("ReceiveWebhook")
+ .WithSummary("Receive, verify, and dispatch an inbound webhook delivery.")
+ .WithTags("Webhooks")
+ .AllowAnonymous();
return app;
}
@@ -210,6 +213,20 @@ internal static string SanitizeWebhookId(string value)
}
}
+/// Error payload returned when a webhook request is malformed.
+internal sealed record WebhookErrorResponse(string Error);
+
+/// Acknowledgement that a webhook delivery was accepted but not acted on.
+internal sealed record WebhookIgnoredResponse(string Status, string Reason);
+
+/// Acknowledgement that a webhook delivery was accepted and dispatched.
+internal sealed record WebhookAcceptedResponse(
+ string Status,
+ string Route,
+ string? EventType,
+ string? DeliveryId,
+ string SessionId);
+
internal enum WebhookBodyReadStatus
{
Ok,