diff --git a/Directory.Packages.props b/Directory.Packages.props index 9c173f0aa..68a226b90 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -25,6 +25,7 @@ + @@ -79,4 +80,4 @@ - + \ No newline at end of file diff --git a/src/Netclaw.Daemon/Configuration/MattermostActionEndpointExtensions.cs b/src/Netclaw.Daemon/Configuration/MattermostActionEndpointExtensions.cs index 2bff39fda..665f36dc8 100644 --- a/src/Netclaw.Daemon/Configuration/MattermostActionEndpointExtensions.cs +++ b/src/Netclaw.Daemon/Configuration/MattermostActionEndpointExtensions.cs @@ -7,6 +7,7 @@ using System.Threading.RateLimiting; using Akka.Actor; using Akka.Hosting; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.RateLimiting; using Netclaw.Actors.Hosting; using Netclaw.Actors.Channels; @@ -46,7 +47,7 @@ public static void MapMattermostActionEndpoint(this WebApplication app) return; } - app.MapPost("/api/mattermost/actions", async ( + app.MapPost("/api/mattermost/actions", async ValueTask, StatusCodeHttpResult, JsonHttpResult>> ( HttpContext httpContext, IRequiredActor gatewayActor, TimeProvider timeProvider, @@ -62,34 +63,34 @@ public static void MapMattermostActionEndpoint(this WebApplication app) } catch (JsonException) { - return Results.BadRequest("Invalid JSON payload."); + return TypedResults.BadRequest("Invalid JSON payload."); } if (payload is null) - return Results.BadRequest("Invalid JSON payload."); + return TypedResults.BadRequest("Invalid JSON payload."); if (payload.RawBodyLength > MaxCallbackBodyBytes) - return Results.StatusCode(StatusCodes.Status413PayloadTooLarge); + return TypedResults.StatusCode(StatusCodes.Status413PayloadTooLarge); if (string.IsNullOrEmpty(payload.UserId) || string.IsNullOrEmpty(payload.PostId) || string.IsNullOrEmpty(payload.ChannelId)) { - return Results.BadRequest("Missing required fields: user_id, post_id, channel_id."); + return TypedResults.BadRequest("Missing required fields: user_id, post_id, channel_id."); } if (payload.Context is null || !payload.Context.TryGetValue("action_token", out var actionToken) || string.IsNullOrWhiteSpace(actionToken)) { - return Results.BadRequest("Missing required context field: action_token."); + return TypedResults.BadRequest("Missing required context field: action_token."); } if (!actionStore.TryConsume(actionToken, out var storedAction) || storedAction is null) { logger.LogWarning("Rejected Mattermost action callback with invalid, expired, or replayed action token"); - return Results.Json(new ActionCallbackResponse + return TypedResults.Json(new ActionCallbackResponse { EphemeralText = "That approval button is no longer valid. Please re-issue the request and try again." }, JsonOptions); @@ -98,7 +99,7 @@ public static void MapMattermostActionEndpoint(this WebApplication app) if (!MattermostAclPolicy.IsAllowedUser(new MattermostUserId(payload.UserId), options)) { logger.LogWarning("Rejected Mattermost action callback from non-allowed user {UserId}", payload.UserId); - return Results.Json(new ActionCallbackResponse + return TypedResults.Json(new ActionCallbackResponse { EphemeralText = "You are not authorized to respond to tool approval prompts." }, JsonOptions); @@ -109,7 +110,7 @@ public static void MapMattermostActionEndpoint(this WebApplication app) logger.LogWarning( "Rejected Mattermost action callback with mismatched routing data channel={ChannelId}", payload.ChannelId); - return Results.BadRequest("Callback routing data did not match the issued action."); + return TypedResults.BadRequest("Callback routing data did not match the issued action."); } // Bound the actor-resolution wait so a daemon still mid-startup @@ -126,7 +127,7 @@ public static void MapMattermostActionEndpoint(this WebApplication app) catch (OperationCanceledException) when (!ct.IsCancellationRequested) { logger.LogError("Mattermost callback received before the Mattermost gateway actor was registered."); - return Results.StatusCode(StatusCodes.Status503ServiceUnavailable); + return TypedResults.StatusCode(StatusCodes.Status503ServiceUnavailable); } var interaction = new MattermostGatewayInteraction( @@ -148,24 +149,28 @@ public static void MapMattermostActionEndpoint(this WebApplication app) return reply switch { - CommandAck => Results.Json(new ActionCallbackResponse + CommandAck => TypedResults.Json(new ActionCallbackResponse { EphemeralText = $"You selected: **{ApprovalOptionKeys.LabelFor(storedAction.SelectedKey)}**" }, JsonOptions), - CommandNack nack => Results.Json(new ActionCallbackResponse + CommandNack nack => TypedResults.Json(new ActionCallbackResponse { EphemeralText = MapRejectMessage(nack.Reason) }, JsonOptions), - _ => Results.StatusCode(StatusCodes.Status500InternalServerError) + _ => TypedResults.StatusCode(StatusCodes.Status500InternalServerError) }; } catch (Exception ex) { logger.LogError(ex, "Failed routing Mattermost action callback for call {CallId}", storedAction.CallId); - return Results.StatusCode(500); + return TypedResults.StatusCode(500); } - }).RequireRateLimiting(CallbackRateLimitPolicy).AllowAnonymous(); + }) + .WithName("MattermostActionCallback") + .WithSummary("Handle a Mattermost interactive approval button callback.") + .WithTags("Mattermost") + .RequireRateLimiting(CallbackRateLimitPolicy).AllowAnonymous(); } private sealed class ActionCallbackPayload @@ -179,9 +184,9 @@ private sealed class ActionCallbackPayload public int RawBodyLength { get; set; } } - private sealed class ActionCallbackResponse + private sealed record ActionCallbackResponse { - public string? EphemeralText { get; set; } + public string? EphemeralText { get; init; } } internal static void AddMattermostActionEndpointRateLimiting(this IServiceCollection services) diff --git a/src/Netclaw.Daemon/Lifecycle/LifecycleEndpointRouteBuilderExtensions.cs b/src/Netclaw.Daemon/Lifecycle/LifecycleEndpointRouteBuilderExtensions.cs index f1dbcf54b..8f5e76bac 100644 --- a/src/Netclaw.Daemon/Lifecycle/LifecycleEndpointRouteBuilderExtensions.cs +++ b/src/Netclaw.Daemon/Lifecycle/LifecycleEndpointRouteBuilderExtensions.cs @@ -3,29 +3,41 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; using Netclaw.Daemon.Services; namespace Netclaw.Daemon.Lifecycle; +/// Request to shut down the daemon, sourced from query string. +public sealed record ShutdownDaemonRequest([FromQuery(Name = "reason")] string? Reason); + +/// Successful shutdown acknowledgement: echoes the reason and reports the daemon PID. +public sealed record ShutdownDaemonResponse(string Reason, int Pid); + +/// Error payload returned when a lifecycle request is malformed. +public sealed record LifecycleErrorResponse(string Error); + public static class LifecycleEndpointRouteBuilderExtensions { public static IEndpointRouteBuilder MapLifecycleEndpoints(this IEndpointRouteBuilder app) { // Daemon lifecycle endpoint — CLI calls this before sending SIGTERM. // Config-triggered restart coordination happens inside DaemonRestartCoordinator. - app.MapPost("/api/lifecycle/shutdown", ( - DaemonLifecycleNotifier notifier, - HttpRequest request) => + app.MapPost("/api/lifecycle/shutdown", Results, BadRequest> ( + [AsParameters] ShutdownDaemonRequest request, + DaemonLifecycleNotifier notifier) => { - var reason = request.Query["reason"].ToString(); - if (string.IsNullOrEmpty(reason)) - return Results.BadRequest(new { error = "reason query parameter is required" }); + if (string.IsNullOrEmpty(request.Reason)) + return TypedResults.BadRequest(new LifecycleErrorResponse("reason query parameter is required")); - notifier.NotifyShutdown(reason); - return Results.Ok(new { reason, pid = Environment.ProcessId }); - }).RequireAuthorization(); + notifier.NotifyShutdown(request.Reason); + return TypedResults.Ok(new ShutdownDaemonResponse(request.Reason, Environment.ProcessId)); + }) + .WithName("ShutdownDaemon") + .WithSummary("Request a graceful daemon shutdown ahead of SIGTERM.") + .WithTags("Lifecycle") + .RequireAuthorization(); return app; } diff --git a/src/Netclaw.Daemon/Mcp/McpEndpointRouteBuilderExtensions.cs b/src/Netclaw.Daemon/Mcp/McpEndpointRouteBuilderExtensions.cs index 511e5189c..90a176cc9 100644 --- a/src/Netclaw.Daemon/Mcp/McpEndpointRouteBuilderExtensions.cs +++ b/src/Netclaw.Daemon/Mcp/McpEndpointRouteBuilderExtensions.cs @@ -4,63 +4,81 @@ // // ----------------------------------------------------------------------- using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Netclaw.Configuration; using Netclaw.Tools; namespace Netclaw.Daemon.Mcp; +/// Query string for the MCP OAuth browser callback. +public sealed record McpOAuthCallbackQuery( + [FromQuery(Name = "code")] string? Code, + [FromQuery(Name = "state")] string? State); + +/// Authorization URL and opaque state returned when an MCP OAuth flow starts. +public sealed record McpOAuthStartResponse(string AuthorizationUrl, string State); + +/// Connection status for a single MCP server. +public sealed record McpServerStatusDto(string State, int ToolCount, string? Error); + +/// OAuth flow status for an MCP server or pending state token. +public sealed record McpOAuthStatusResponse(string Status); + +/// Error payload returned when an MCP request is malformed or unknown. +public sealed record McpErrorResponse(string Error); + public static class McpEndpointRouteBuilderExtensions { public static IEndpointRouteBuilder MapMcpEndpoints(this IEndpointRouteBuilder app) { // MCP OAuth 2.1 endpoints - app.MapPost("/api/mcp/oauth/start/{name}", async ( + app.MapPost("/api/mcp/oauth/start/{name}", async ValueTask, NotFound, BadRequest>> ( string name, McpOAuthService oauthService, Dictionary mcpServers, CancellationToken ct) => { if (!mcpServers.TryGetValue(name, out var entry)) - return Results.NotFound(new { error = $"MCP server '{name}' not found" }); + return TypedResults.NotFound(new McpErrorResponse($"MCP server '{name}' not found")); if (string.IsNullOrWhiteSpace(entry.Url)) - return Results.BadRequest(new { error = $"MCP server '{name}' has no URL (OAuth requires HTTP transport)" }); + return TypedResults.BadRequest(new McpErrorResponse($"MCP server '{name}' has no URL (OAuth requires HTTP transport)")); var (authUrl, state) = await oauthService.StartAuthorizationFlowAsync(new McpServerName(name), entry, ct); - return Results.Ok(new { authorizationUrl = authUrl, state }); - }).RequireAuthorization(); + return TypedResults.Ok(new McpOAuthStartResponse(authUrl, state)); + }) + .WithName("StartMcpOAuth") + .WithSummary("Start an OAuth 2.1 authorization flow for an MCP server.") + .WithTags("MCP") + .RequireAuthorization(); - app.MapGet("/api/mcp/oauth/callback", async ( - HttpContext context, + app.MapGet("/api/mcp/oauth/callback", async ValueTask ( + [AsParameters] McpOAuthCallbackQuery query, McpOAuthService oauthService, + IMcpReconnectable mcpManager, + ILogger reconnectLogger, 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 oauthService.CompleteAuthorizationAsync(code, state, ct); + await oauthService.CompleteAuthorizationAsync(query.Code, query.State, ct); // Auto-reconnect the MCP server now that we have a valid token. // Resolved as IMcpReconnectable so the callback is not coupled to the // concrete McpClientManager type, which is heavyweight and hard to stub in tests. - var serverName = oauthService.GetServerNameForState(state); + var serverName = oauthService.GetServerNameForState(query.State); if (serverName is not null) { - var mcpManager = context.RequestServices.GetRequiredService(); - var reconnectLogger = context.RequestServices.GetRequiredService>(); _ = Task.Run(async () => { try { await mcpManager.TryReconnectAsync(serverName.Value, CancellationToken.None); } @@ -68,51 +86,70 @@ await context.Response.WriteAsync( }, CancellationToken.None); } - context.Response.ContentType = "text/html"; - await context.Response.WriteAsync( - "

Authorization complete

You may close this tab.

", ct); + return TypedResults.Content( + "

Authorization complete

You may close this tab.

", + "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("McpOAuthCallback") + .WithSummary("Browser redirect callback that completes an MCP OAuth flow.") + .WithTags("MCP") + .AllowAnonymous(); app.MapGet("/api/mcp/statuses", (McpClientManager mcpManager) => { var statuses = mcpManager.GetServerStatuses(); var result = statuses.ToDictionary( kvp => kvp.Key.Value, - kvp => new - { - state = kvp.Value.State.ToString(), - toolCount = kvp.Value.ToolCount, - error = kvp.Value.ErrorMessage, - }); - return Results.Ok(result); - }).RequireAuthorization(); + kvp => new McpServerStatusDto( + kvp.Value.State.ToString(), + kvp.Value.ToolCount, + kvp.Value.ErrorMessage)); + return TypedResults.Ok(result); + }) + .WithName("GetMcpServerStatuses") + .WithSummary("Get the connection status of all configured MCP servers.") + .WithTags("MCP") + .RequireAuthorization(); app.MapGet("/api/mcp/tools/{name}", (string name, McpClientManager mcpManager) => { var tools = mcpManager.GetToolNames(new McpServerName(name)); - return Results.Ok(tools); - }).RequireAuthorization(); + return TypedResults.Ok(tools); + }) + .WithName("GetMcpServerTools") + .WithSummary("List the tool names exposed by a single MCP server.") + .WithTags("MCP") + .RequireAuthorization(); app.MapGet("/api/mcp/oauth/status/{name}", (string name, McpOAuthService oauthService) => { var status = oauthService.GetFlowStatus(new McpServerName(name)); - return Results.Ok(new { status = status.ToString() }); - }).RequireAuthorization(); + return TypedResults.Ok(new McpOAuthStatusResponse(status.ToString())); + }) + .WithName("GetMcpOAuthStatus") + .WithSummary("Get the OAuth flow status for an MCP server by name.") + .WithTags("MCP") + .RequireAuthorization(); app.MapGet("/api/mcp/oauth/status-by-state/{state}", (string state, McpOAuthService oauthService) => { var status = oauthService.GetFlowStatusByState(state); // Tokens are persisted daemon-side — never expose them over HTTP. - return Results.Ok(new { status = status.ToString() }); - }).RequireAuthorization(); + return TypedResults.Ok(new McpOAuthStatusResponse(status.ToString())); + }) + .WithName("GetMcpOAuthStatusByState") + .WithSummary("Get the OAuth flow status for an MCP server by state token.") + .WithTags("MCP") + .RequireAuthorization(); return app; } diff --git a/src/Netclaw.Daemon/Netclaw.Daemon.csproj b/src/Netclaw.Daemon/Netclaw.Daemon.csproj index d12d77a3e..a801d7436 100644 --- a/src/Netclaw.Daemon/Netclaw.Daemon.csproj +++ b/src/Netclaw.Daemon/Netclaw.Daemon.csproj @@ -1,55 +1,65 @@ - - net10.0 - netclawd - Exe - enable - enable - true - false - - $(NoWarn);OPENAI001 - + + net10.0 + netclawd + Exe + enable + enable + true + false + + $(NoWarn);OPENAI001 - - - - + + true + + $(NoWarn);CS1573;CS1574;CS1587;CS0419 + - - - - - - - - - - - - + + + + - - - PreserveNewest - - - - PreserveNewest - - + + + + + + + + + + + + + - - - - - - - - - - - + + + PreserveNewest + + + + PreserveNewest + + + + + + + + + + + + + + diff --git a/src/Netclaw.Daemon/Program.cs b/src/Netclaw.Daemon/Program.cs index 883cbb3b0..7f19587d6 100644 --- a/src/Netclaw.Daemon/Program.cs +++ b/src/Netclaw.Daemon/Program.cs @@ -8,6 +8,7 @@ using Akka.Hosting; using Akka.Persistence.Hosting; using Akka.Persistence.Sql.Hosting; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Extensions.Configuration; @@ -135,6 +136,9 @@ static async Task RunDaemonAsync(string[] args, DaemonRestartSignal restartSigna builder.Services.AddNetclawAuthSchemes(daemonConfig); builder.Services.AddAuthorization(); + // Add OpenAPI + builder.Services.AddOpenApi(); + // Rate limiting for the unauthenticated pairing exchange endpoint. // 5 attempts per minute per IP — brute-force defense for the 8-char code space. builder.Services.AddRateLimiter(options => @@ -203,17 +207,43 @@ static async Task RunDaemonAsync(string[] args, DaemonRestartSignal restartSigna app.UseAuthorization(); app.UseRateLimiter(); + // Require authorization for the OpenAPI document so the full API surface is not + // exposed to unauthenticated callers when the daemon binds to a non-loopback + // address (e.g. ExposureMode.ReverseProxy). Loopback callers are still served: + // the AuthSelector routes them to LoopbackAuthenticationHandler, which issues an + // authenticated Operator ticket that satisfies the default policy. + app.MapOpenApi().RequireAuthorization(); + // Gateway surface app.MapHub("/hub/session"); - app.MapGet("/api/health/ready", () => Results.Ok("healthy")); - app.MapGet("/api/health/status", async (DaemonRuntimeStatusService statusService, CancellationToken cancellationToken) => - Results.Ok(await statusService.GetStatusAsync(cancellationToken))).RequireAuthorization(); + app.MapGet("/api/health/ready", () => TypedResults.Ok("healthy")) + .WithName("HealthReady") + .WithSummary("Liveness probe reporting the daemon is accepting requests.") + .WithTags("Health"); + app.MapGet("/api/health/status", async ValueTask> (DaemonRuntimeStatusService statusService, CancellationToken cancellationToken) => + TypedResults.Ok(await statusService.GetStatusAsync(cancellationToken))) + .WithName("GetHealthStatus") + .WithSummary("Get the daemon's runtime status, including connector health.") + .WithTags("Health") + .RequireAuthorization(); app.MapGet("/api/sessions", (SessionCatalogService catalog) => - Results.Ok(catalog.ListRecent(limit: 50))).RequireAuthorization(); - app.MapGet("/api/stats", async (DaemonStatsService statsService, int? days, CancellationToken ct) => - Results.Ok(await statsService.GetStatsAsync(days, ct))).RequireAuthorization(); - app.MapGet("/api/stats/skills", async (DaemonStatsService statsService, int? days, CancellationToken ct) => - Results.Ok(await statsService.GetSkillUsageStatsAsync(days, ct))).RequireAuthorization(); + TypedResults.Ok(catalog.ListRecent(limit: 50))) + .WithName("ListSessions") + .WithSummary("List the most recent sessions.") + .WithTags("Sessions") + .RequireAuthorization(); + app.MapGet("/api/stats", async ValueTask> (DaemonStatsService statsService, int? days, CancellationToken ct) => + TypedResults.Ok(await statsService.GetStatsAsync(days, ct))) + .WithName("GetStats") + .WithSummary("Get daemon usage statistics over the requested window.") + .WithTags("Stats") + .RequireAuthorization(); + app.MapGet("/api/stats/skills", async ValueTask> (DaemonStatsService statsService, int? days, CancellationToken ct) => + TypedResults.Ok(await statsService.GetSkillUsageStatsAsync(days, ct))) + .WithName("GetSkillUsageStats") + .WithSummary("Get per-skill usage statistics over the requested window.") + .WithTags("Stats") + .RequireAuthorization(); app.MapWebhookEndpoints(); app.MapMattermostActionEndpoint(); diff --git a/src/Netclaw.Daemon/Providers/ProviderOAuthEndpointRouteBuilderExtensions.cs b/src/Netclaw.Daemon/Providers/ProviderOAuthEndpointRouteBuilderExtensions.cs index 0299a394e..f0e8ee272 100644 --- a/src/Netclaw.Daemon/Providers/ProviderOAuthEndpointRouteBuilderExtensions.cs +++ b/src/Netclaw.Daemon/Providers/ProviderOAuthEndpointRouteBuilderExtensions.cs @@ -1,33 +1,59 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; using Netclaw.Providers; using Netclaw.Providers.OAuth; namespace Netclaw.Daemon.Providers; +/// Query string identifying which provider's OAuth flow to start. +internal sealed record ProviderOAuthStartQuery([FromQuery(Name = "provider")] string? Provider); + +/// Query string for the provider OAuth browser callback. +internal sealed record ProviderOAuthCallbackQuery( + [FromQuery(Name = "code")] string? Code, + [FromQuery(Name = "state")] string? State); + +/// Authorization URL and opaque state returned when a provider OAuth flow starts. +internal sealed record ProviderOAuthStartResponse(string AuthorizationUrl, string State); + +/// +/// Status of a provider OAuth flow. Tokens are only populated for loopback callers; +/// remote paired devices see boolean flags only to prevent credential exfiltration. +/// +internal sealed record ProviderOAuthStatusResponse( + string Status, + bool HasToken, + string? AccessToken, + string? RefreshToken, + string? ExpiresAt); + +/// Error payload returned when a provider OAuth request is malformed or unknown. +internal sealed record ProviderOAuthErrorResponse(string Error); + internal static class ProviderOAuthEndpointRouteBuilderExtensions { public static IEndpointRouteBuilder MapProviderOAuthEndpoints(this IEndpointRouteBuilder app) { - app.MapPost("/api/provider/oauth/start", ( - HttpContext context, + app.MapPost("/api/provider/oauth/start", Results, BadRequest, NotFound> ( + [AsParameters] ProviderOAuthStartQuery query, OAuthPkceService pkceService, ProviderDescriptorRegistry registry, IProviderOAuthCallbackListener callbackListener) => { - var providerType = context.Request.Query["provider"].ToString(); - if (string.IsNullOrEmpty(providerType)) - return Results.BadRequest(new { error = "Missing 'provider' query parameter" }); + if (string.IsNullOrEmpty(query.Provider)) + return TypedResults.BadRequest(new ProviderOAuthErrorResponse("Missing 'provider' query parameter")); - if (!registry.TryGet(providerType, out var descriptor)) - return Results.NotFound(new { error = $"Unknown provider type: {providerType}" }); + if (!registry.TryGet(query.Provider, out var descriptor)) + return TypedResults.NotFound(new ProviderOAuthErrorResponse($"Unknown provider type: {query.Provider}")); var oauth = descriptor.Auth.GetOAuthConfig(); if (oauth is null || oauth.AuthorizationEndpoint is null || oauth.RedirectUri is null) - return Results.BadRequest(new { error = $"Provider '{providerType}' does not support browser OAuth" }); + return TypedResults.BadRequest(new ProviderOAuthErrorResponse($"Provider '{query.Provider}' does not support browser OAuth")); var (authUrl, state) = pkceService.StartAuthorizationFlow( oauth.AuthorizationEndpoint.AbsoluteUri, @@ -39,40 +65,45 @@ public static IEndpointRouteBuilder MapProviderOAuthEndpoints(this IEndpointRout callbackListener.StartListening(oauth.RedirectUri.AbsoluteUri, state); - return Results.Ok(new { authorizationUrl = authUrl, state }); - }).RequireAuthorization(); + return TypedResults.Ok(new ProviderOAuthStartResponse(authUrl, state)); + }) + .WithName("StartProviderOAuth") + .WithSummary("Start a browser OAuth authorization flow for a model provider.") + .WithTags("Provider OAuth") + .RequireAuthorization(); - app.MapGet("/api/provider/oauth/callback", async ( - HttpContext context, + app.MapGet("/api/provider/oauth/callback", async ValueTask ( + [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,