From 4a24f0804ff0341b3bace5ecad62fe533c7e4e1d Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 30 Mar 2026 22:32:52 +0100 Subject: [PATCH 1/8] Enable XML doc generation and enhance Swagger configuration Add OpenAPI metadata (title, description, version, contact, license), JWT Bearer security definition, and XML comment inclusion to the Swagger generation pipeline. Enable GenerateDocumentationFile in the API project to feed XML comments into the OpenAPI spec. Closes part of #99 --- backend/src/Taskdeck.Api/Program.cs | 56 +++++++++++++++++++- backend/src/Taskdeck.Api/Taskdeck.Api.csproj | 2 + 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/backend/src/Taskdeck.Api/Program.cs b/backend/src/Taskdeck.Api/Program.cs index e9321ebc3..279897361 100644 --- a/backend/src/Taskdeck.Api/Program.cs +++ b/backend/src/Taskdeck.Api/Program.cs @@ -1,5 +1,7 @@ +using System.Reflection; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.OpenApi.Models; using Taskdeck.Api.Extensions; using Taskdeck.Api.FirstRun; using Taskdeck.Infrastructure; @@ -21,7 +23,59 @@ builder.Services.AddControllers(); builder.Services.AddSignalR(); builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc("v1", new OpenApiInfo + { + Title = "Taskdeck API", + Version = "v1", + Description = "Local-first execution workspace API. Provides board management, capture pipeline, " + + "chat-to-proposal automation, webhook integrations, and review-first governance.", + Contact = new OpenApiContact + { + Name = "Taskdeck Contributors", + Url = new Uri("https://github.com/Chris0Jeky/Taskdeck") + }, + License = new OpenApiLicense + { + Name = "MIT" + } + }); + + options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + Description = "JWT Authorization header using the Bearer scheme. " + + "Enter your token in the text input below. Example: 'eyJhbGci...'", + Name = "Authorization", + In = ParameterLocation.Header, + Type = SecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "JWT" + }); + + options.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }, + Array.Empty() + } + }); + + // Include XML comments from the API project + var xmlFilename = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; + var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFilename); + if (File.Exists(xmlPath)) + { + options.IncludeXmlComments(xmlPath); + } +}); // Bind configuration settings (observability, rate limiting, security headers, JWT, etc.) builder.Services.AddTaskdeckSettings( diff --git a/backend/src/Taskdeck.Api/Taskdeck.Api.csproj b/backend/src/Taskdeck.Api/Taskdeck.Api.csproj index 7614f6b42..17cf75cd6 100644 --- a/backend/src/Taskdeck.Api/Taskdeck.Api.csproj +++ b/backend/src/Taskdeck.Api/Taskdeck.Api.csproj @@ -4,6 +4,8 @@ net8.0 enable enable + true + $(NoWarn);1591 From 0da264fba7df2bd2a0f39528ac7aea2f4344a8fd Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 30 Mar 2026 22:33:08 +0100 Subject: [PATCH 2/8] Add OpenAPI annotations to Boards, Cards, and Columns controllers Add XML documentation summaries, parameter descriptions, and ProducesResponseType attributes to all endpoints on BoardsController, CardsController, and ColumnsController for richer generated API docs. Part of #99 --- .../Controllers/BoardsController.cs | 60 ++++++++++++ .../Controllers/CardsController.cs | 94 +++++++++++++++++++ .../Controllers/ColumnsController.cs | 73 ++++++++++++++ 3 files changed, 227 insertions(+) diff --git a/backend/src/Taskdeck.Api/Controllers/BoardsController.cs b/backend/src/Taskdeck.Api/Controllers/BoardsController.cs index 37ff8f748..31b5909a0 100644 --- a/backend/src/Taskdeck.Api/Controllers/BoardsController.cs +++ b/backend/src/Taskdeck.Api/Controllers/BoardsController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Taskdeck.Api.Contracts; using Taskdeck.Api.Extensions; using Taskdeck.Application.DTOs; using Taskdeck.Application.Interfaces; @@ -7,9 +8,14 @@ namespace Taskdeck.Api.Controllers; +/// +/// Manage boards for the authenticated user. Boards are the top-level container +/// for columns, cards, and labels. +/// [ApiController] [Authorize] [Route("api/[controller]")] +[Produces("application/json")] public class BoardsController : AuthenticatedControllerBase { private readonly BoardService _boardService; @@ -19,7 +25,17 @@ public BoardsController(BoardService boardService, IUserContext userContext) : b _boardService = boardService; } + /// + /// List boards accessible to the current user. + /// + /// Optional text filter applied to board names. + /// When true, archived boards are included in the results. + /// A list of boards matching the criteria. + /// Returns the list of boards. + /// Authentication required. [HttpGet] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] public async Task GetBoards([FromQuery] string? search, [FromQuery] bool includeArchived = false) { if (!TryGetCurrentUserId(out var userId, out var errorResult)) @@ -29,7 +45,18 @@ public async Task GetBoards([FromQuery] string? search, [FromQuer return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); } + /// + /// Get a board by ID, including its columns. + /// + /// The board identifier. + /// The board detail including columns. + /// Returns the board detail. + /// Authentication required. + /// Board not found or not accessible. [HttpGet("{id}")] + [ProducesResponseType(typeof(BoardDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] public async Task GetBoard(Guid id) { if (!TryGetCurrentUserId(out var userId, out var errorResult)) @@ -39,7 +66,18 @@ public async Task GetBoard(Guid id) return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); } + /// + /// Create a new board. + /// + /// Board creation parameters. + /// The newly created board. + /// Board created successfully. + /// Validation error in the request body. + /// Authentication required. [HttpPost] + [ProducesResponseType(typeof(BoardDto), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] public async Task CreateBoard([FromBody] CreateBoardDto dto) { if (!TryGetCurrentUserId(out var userId, out var errorResult)) @@ -51,7 +89,19 @@ public async Task CreateBoard([FromBody] CreateBoardDto dto) : result.ToErrorActionResult(); } + /// + /// Update an existing board. + /// + /// The board identifier. + /// Fields to update (all optional). + /// The updated board. + /// Board updated successfully. + /// Authentication required. + /// Board not found or not accessible. [HttpPut("{id}")] + [ProducesResponseType(typeof(BoardDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] public async Task UpdateBoard(Guid id, [FromBody] UpdateBoardDto dto) { if (!TryGetCurrentUserId(out var userId, out var errorResult)) @@ -61,7 +111,17 @@ public async Task UpdateBoard(Guid id, [FromBody] UpdateBoardDto return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); } + /// + /// Delete a board. + /// + /// The board identifier. + /// Board deleted successfully. + /// Authentication required. + /// Board not found or not accessible. [HttpDelete("{id}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] public async Task DeleteBoard(Guid id) { if (!TryGetCurrentUserId(out var userId, out var errorResult)) diff --git a/backend/src/Taskdeck.Api/Controllers/CardsController.cs b/backend/src/Taskdeck.Api/Controllers/CardsController.cs index 85922a3d1..91cf69047 100644 --- a/backend/src/Taskdeck.Api/Controllers/CardsController.cs +++ b/backend/src/Taskdeck.Api/Controllers/CardsController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Taskdeck.Api.Contracts; using Taskdeck.Api.Extensions; using Taskdeck.Application.DTOs; using Taskdeck.Application.Interfaces; @@ -8,9 +9,14 @@ namespace Taskdeck.Api.Controllers; +/// +/// Manage cards within a board. Cards represent individual work items and belong +/// to a column. Supports search, move, and capture provenance tracking. +/// [ApiController] [Authorize] [Route("api/boards/{boardId}/cards")] +[Produces("application/json")] public class CardsController : AuthenticatedControllerBase { private readonly CardService _cardService; @@ -26,7 +32,21 @@ public CardsController( _authorizationService = authorizationService; } + /// + /// Search cards on a board with optional filters. + /// + /// The board identifier. + /// Optional text search across card titles and descriptions. + /// Filter cards by label. + /// Filter cards by column. + /// A list of cards matching the criteria. + /// Returns matching cards. + /// Authentication required. + /// User does not have read access to this board. [HttpGet] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)] public async Task GetCards( Guid boardId, [FromQuery] string? search, @@ -50,7 +70,22 @@ public async Task GetCards( return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); } + /// + /// Get capture provenance for a card, showing the link back to the + /// capture item and proposal that created it. + /// + /// The board identifier. + /// The card identifier. + /// Provenance details linking the card to its capture origin. + /// Returns the provenance record. + /// Authentication required. + /// User does not have read access to this board. + /// Card or provenance not found. [HttpGet("{cardId}/provenance")] + [ProducesResponseType(typeof(CardCaptureProvenanceDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] public async Task GetCardProvenance(Guid boardId, Guid cardId) { if (!TryGetCurrentUserId(out var userId, out var errorResult)) @@ -70,7 +105,21 @@ public async Task GetCardProvenance(Guid boardId, Guid cardId) return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); } + /// + /// Create a new card on a board. + /// + /// The board identifier. + /// Card creation parameters including title, column, and optional labels. + /// The newly created card. + /// Card created successfully. + /// Validation error (e.g., WIP limit exceeded). + /// Authentication required. + /// User does not have write access to this board. [HttpPost] + [ProducesResponseType(typeof(CardDto), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)] public async Task CreateCard(Guid boardId, [FromBody] CreateCardDto dto) { if (!TryGetCurrentUserId(out var userId, out var errorResult)) @@ -93,7 +142,24 @@ public async Task CreateCard(Guid boardId, [FromBody] CreateCardD : result.ToErrorActionResult(); } + /// + /// Update card fields. Supports optimistic concurrency via ExpectedUpdatedAt. + /// + /// The board identifier. + /// The card identifier. + /// Fields to update (all optional). Include ExpectedUpdatedAt for conflict detection. + /// The updated card. + /// Card updated successfully. + /// Authentication required. + /// User does not have write access to this board. + /// Card not found. + /// Conflict — the card was modified since ExpectedUpdatedAt. [HttpPatch("{cardId}")] + [ProducesResponseType(typeof(CardDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)] public async Task UpdateCard(Guid boardId, Guid cardId, [FromBody] UpdateCardDto dto) { if (!TryGetCurrentUserId(out var userId, out var errorResult)) @@ -113,7 +179,22 @@ public async Task UpdateCard(Guid boardId, Guid cardId, [FromBody return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); } + /// + /// Move a card to a different column and/or position. + /// + /// The board identifier. + /// The card identifier. + /// Target column and position. + /// The moved card with updated column and position. + /// Card moved successfully. + /// Validation error (e.g., WIP limit exceeded on target column). + /// Authentication required. + /// User does not have write access to this board. [HttpPost("{cardId}/move")] + [ProducesResponseType(typeof(CardDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)] public async Task MoveCard(Guid boardId, Guid cardId, [FromBody] MoveCardDto dto) { if (!TryGetCurrentUserId(out var userId, out var errorResult)) @@ -133,7 +214,20 @@ public async Task MoveCard(Guid boardId, Guid cardId, [FromBody] return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); } + /// + /// Delete a card from a board. + /// + /// The board identifier. + /// The card identifier. + /// Card deleted successfully. + /// Authentication required. + /// User does not have write access to this board. + /// Card not found. [HttpDelete("{cardId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] public async Task DeleteCard(Guid boardId, Guid cardId) { if (!TryGetCurrentUserId(out var userId, out var errorResult)) diff --git a/backend/src/Taskdeck.Api/Controllers/ColumnsController.cs b/backend/src/Taskdeck.Api/Controllers/ColumnsController.cs index e77355c82..6bb527298 100644 --- a/backend/src/Taskdeck.Api/Controllers/ColumnsController.cs +++ b/backend/src/Taskdeck.Api/Controllers/ColumnsController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Taskdeck.Api.Contracts; using Taskdeck.Api.Extensions; using Taskdeck.Application.DTOs; using Taskdeck.Application.Interfaces; @@ -8,9 +9,14 @@ namespace Taskdeck.Api.Controllers; +/// +/// Manage columns within a board. Columns organize cards into workflow stages +/// and support optional WIP (work-in-progress) limits. +/// [ApiController] [Authorize] [Route("api/boards/{boardId}/columns")] +[Produces("application/json")] public class ColumnsController : AuthenticatedControllerBase { private readonly ColumnService _columnService; @@ -26,7 +32,18 @@ public ColumnsController( _authorizationService = authorizationService; } + /// + /// List all columns for a board, ordered by position. + /// + /// The board identifier. + /// An ordered list of columns. + /// Returns the columns for the board. + /// Authentication required. + /// User does not have read access to this board. [HttpGet] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)] public async Task GetColumns(Guid boardId) { if (!TryGetCurrentUserId(out var userId, out var errorResult)) @@ -46,7 +63,21 @@ public async Task GetColumns(Guid boardId) return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); } + /// + /// Create a new column on a board. + /// + /// The board identifier. + /// Column creation parameters including name and optional WIP limit. + /// The newly created column. + /// Column created successfully. + /// Validation error. + /// Authentication required. + /// User does not have write access to this board. [HttpPost] + [ProducesResponseType(typeof(ColumnDto), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)] public async Task CreateColumn(Guid boardId, [FromBody] CreateColumnDto dto) { if (!TryGetCurrentUserId(out var userId, out var errorResult)) @@ -70,7 +101,22 @@ public async Task CreateColumn(Guid boardId, [FromBody] CreateCol : result.ToErrorActionResult(); } + /// + /// Update a column's name, position, or WIP limit. + /// + /// The board identifier. + /// The column identifier. + /// Fields to update (all optional). + /// The updated column. + /// Column updated successfully. + /// Authentication required. + /// User does not have write access to this board. + /// Column not found. [HttpPatch("{columnId}")] + [ProducesResponseType(typeof(ColumnDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] public async Task UpdateColumn(Guid boardId, Guid columnId, [FromBody] UpdateColumnDto dto) { if (!TryGetCurrentUserId(out var userId, out var errorResult)) @@ -90,7 +136,20 @@ public async Task UpdateColumn(Guid boardId, Guid columnId, [From return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); } + /// + /// Delete a column from a board. The column must be empty (no cards). + /// + /// The board identifier. + /// The column identifier. + /// Column deleted successfully. + /// Authentication required. + /// User does not have write access to this board. + /// Column not found. [HttpDelete("{columnId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] public async Task DeleteColumn(Guid boardId, Guid columnId) { if (!TryGetCurrentUserId(out var userId, out var errorResult)) @@ -110,7 +169,21 @@ public async Task DeleteColumn(Guid boardId, Guid columnId) return result.IsSuccess ? NoContent() : result.ToErrorActionResult(); } + /// + /// Reorder columns by providing the full list of column IDs in the desired order. + /// + /// The board identifier. + /// The ordered list of column IDs. + /// The reordered columns. + /// Columns reordered successfully. + /// Validation error (e.g., missing or extra column IDs). + /// Authentication required. + /// User does not have write access to this board. [HttpPost("reorder")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)] public async Task ReorderColumns(Guid boardId, [FromBody] ReorderColumnsDto dto) { if (!TryGetCurrentUserId(out var userId, out var errorResult)) From b9736c3f15aa70cac7606a7930d1a3372e671b6d Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 30 Mar 2026 22:33:13 +0100 Subject: [PATCH 3/8] Add OpenAPI annotations to Auth, Capture, Chat, and Webhooks controllers Add XML documentation summaries, parameter descriptions, and ProducesResponseType attributes to AuthController, CaptureController, ChatController, and OutboundWebhooksController for enriched OpenAPI spec. Part of #99 --- .../Controllers/AuthController.cs | 39 +++++++++ .../Controllers/CaptureController.cs | 83 +++++++++++++++++++ .../Controllers/ChatController.cs | 76 +++++++++++++++++ .../Controllers/OutboundWebhooksController.cs | 65 +++++++++++++++ 4 files changed, 263 insertions(+) diff --git a/backend/src/Taskdeck.Api/Controllers/AuthController.cs b/backend/src/Taskdeck.Api/Controllers/AuthController.cs index bf39b9a59..1fa981539 100644 --- a/backend/src/Taskdeck.Api/Controllers/AuthController.cs +++ b/backend/src/Taskdeck.Api/Controllers/AuthController.cs @@ -17,8 +17,13 @@ namespace Taskdeck.Api.Controllers; public record ChangePasswordRequest(Guid UserId, string CurrentPassword, string NewPassword); public record ExchangeCodeRequest(string Code); +/// +/// Authentication endpoints — register, login, change password, and GitHub OAuth flow. +/// All endpoints return a JWT token on successful authentication. +/// [ApiController] [Route("api/auth")] +[Produces("application/json")] public class AuthController : ControllerBase { private readonly AuthenticationService _authService; @@ -34,9 +39,20 @@ public AuthController(AuthenticationService authService, GitHubOAuthSettings git _gitHubOAuthSettings = gitHubOAuthSettings; } + /// + /// Authenticate with username/email and password. Returns a JWT token. + /// + /// Login credentials. + /// JWT token and user profile. + /// Login successful — JWT token returned. + /// Invalid credentials. + /// Rate limit exceeded. [HttpPost("login")] [SuppressModelStateValidation] [EnableRateLimiting(RateLimitingPolicyNames.AuthPerIp)] + [ProducesResponseType(typeof(AuthResultDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)] public async Task Login([FromBody] LoginDto? dto) { if (dto is null @@ -52,16 +68,39 @@ public async Task Login([FromBody] LoginDto? dto) return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); } + /// + /// Register a new user account. Returns a JWT token. + /// + /// Registration details: username, email, password. + /// JWT token and user profile. + /// Registration successful — JWT token returned. + /// Validation error (e.g., duplicate username/email). + /// Rate limit exceeded. [HttpPost("register")] [EnableRateLimiting(RateLimitingPolicyNames.AuthPerIp)] + [ProducesResponseType(typeof(AuthResultDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)] public async Task Register([FromBody] CreateUserDto dto) { var result = await _authService.RegisterAsync(dto); return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); } + /// + /// Change the password for an existing user. + /// + /// Current and new password. + /// Password changed successfully. + /// Validation error. + /// Current password is incorrect. + /// Rate limit exceeded. [HttpPost("change-password")] [EnableRateLimiting(RateLimitingPolicyNames.AuthPerIp)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)] public async Task ChangePassword([FromBody] ChangePasswordRequest request) { var result = await _authService.ChangePasswordAsync(request.UserId, request.CurrentPassword, request.NewPassword); diff --git a/backend/src/Taskdeck.Api/Controllers/CaptureController.cs b/backend/src/Taskdeck.Api/Controllers/CaptureController.cs index 8ce8a4015..708b722a8 100644 --- a/backend/src/Taskdeck.Api/Controllers/CaptureController.cs +++ b/backend/src/Taskdeck.Api/Controllers/CaptureController.cs @@ -12,9 +12,15 @@ namespace Taskdeck.Api.Controllers; +/// +/// Capture pipeline — the entry point for quick-capture of ideas, tasks, and notes. +/// Captured items flow through triage to generate automation proposals that appear +/// in the review queue for user approval before any board mutation occurs. +/// [ApiController] [Authorize] [Route("api/capture/items")] +[Produces("application/json")] public class CaptureController : AuthenticatedControllerBase { private readonly ICaptureService _captureService; @@ -25,8 +31,22 @@ public CaptureController(ICaptureService captureService, IUserContext userContex _captureService = captureService; } + /// + /// Create a new capture item. This is the primary quick-capture endpoint. + /// + /// Capture item content including text and optional board target. + /// Cancellation token. + /// The newly created capture item. + /// Capture item created successfully. + /// Validation error. + /// Authentication required. + /// Rate limit exceeded. [HttpPost] [EnableRateLimiting(RateLimitingPolicyNames.CaptureWritePerUser)] + [ProducesResponseType(typeof(CaptureItemDto), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)] public async Task Create([FromBody] CreateCaptureItemDto dto, CancellationToken cancellationToken) { if (!TryGetCurrentUserId(out var userId, out var errorResult)) @@ -39,7 +59,21 @@ public async Task Create([FromBody] CreateCaptureItemDto dto, Can return CreatedAtAction(nameof(GetById), new { id = result.Value.Id }, result.Value); } + /// + /// List capture items for the current user with optional filters. + /// + /// Filter by capture status (e.g., Pending, Triaging, Processed, Ignored, Cancelled). + /// Filter by target board. + /// Maximum number of items to return (default 50). + /// Cancellation token. + /// A list of capture items. + /// Returns the capture items. + /// Invalid status value. + /// Authentication required. [HttpGet] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] public async Task List( [FromQuery] string? status = null, [FromQuery] Guid? boardId = null, @@ -67,7 +101,19 @@ public async Task List( return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); } + /// + /// Get a single capture item by ID. + /// + /// The capture item identifier. + /// Cancellation token. + /// The capture item. + /// Returns the capture item. + /// Authentication required. + /// Capture item not found. [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(CaptureItemDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] public async Task GetById(Guid id, CancellationToken cancellationToken) { if (!TryGetCurrentUserId(out var userId, out var errorResult)) @@ -77,7 +123,18 @@ public async Task GetById(Guid id, CancellationToken cancellation return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); } + /// + /// Mark a capture item as ignored (dismissed without triage). + /// + /// The capture item identifier. + /// Cancellation token. + /// Item ignored successfully. + /// Authentication required. + /// Capture item not found. [HttpPost("{id:guid}/ignore")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] public async Task Ignore(Guid id, CancellationToken cancellationToken) { if (!TryGetCurrentUserId(out var userId, out var errorResult)) @@ -87,7 +144,18 @@ public async Task Ignore(Guid id, CancellationToken cancellationT return result.IsSuccess ? NoContent() : result.ToErrorActionResult(); } + /// + /// Cancel a capture item (e.g., if it was submitted in error). + /// + /// The capture item identifier. + /// Cancellation token. + /// Item cancelled successfully. + /// Authentication required. + /// Capture item not found. [HttpPost("{id:guid}/cancel")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] public async Task Cancel(Guid id, CancellationToken cancellationToken) { if (!TryGetCurrentUserId(out var userId, out var errorResult)) @@ -97,8 +165,23 @@ public async Task Cancel(Guid id, CancellationToken cancellationT return result.IsSuccess ? NoContent() : result.ToErrorActionResult(); } + /// + /// Enqueue a capture item for triage. The system will generate an automation + /// proposal that the user must review before any board changes are applied. + /// + /// The capture item identifier. + /// Cancellation token. + /// Triage enqueue result with status information. + /// Triage enqueued — proposal will be generated asynchronously. + /// Authentication required. + /// Capture item not found. + /// Rate limit exceeded. [HttpPost("{id:guid}/triage")] [EnableRateLimiting(RateLimitingPolicyNames.CaptureWritePerUser)] + [ProducesResponseType(typeof(CaptureTriageEnqueueResultDto), StatusCodes.Status202Accepted)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)] public async Task EnqueueTriage(Guid id, CancellationToken cancellationToken) { if (!TryGetCurrentUserId(out var userId, out var errorResult)) diff --git a/backend/src/Taskdeck.Api/Controllers/ChatController.cs b/backend/src/Taskdeck.Api/Controllers/ChatController.cs index 58d548b9f..0c9a646d6 100644 --- a/backend/src/Taskdeck.Api/Controllers/ChatController.cs +++ b/backend/src/Taskdeck.Api/Controllers/ChatController.cs @@ -11,9 +11,14 @@ namespace Taskdeck.Api.Controllers; +/// +/// LLM-powered chat sessions. Messages can trigger automation proposals that +/// flow into the review queue. Supports real-time streaming via SSE. +/// [ApiController] [Authorize] [Route("api/llm/chat")] +[Produces("application/json")] public class ChatController : AuthenticatedControllerBase { private readonly IChatService _chatService; @@ -23,8 +28,22 @@ public ChatController(IChatService chatService, IUserContext userContext) : base _chatService = chatService; } + /// + /// Create a new chat session, optionally scoped to a board. + /// + /// Session parameters including title and optional board scope. + /// Cancellation token. + /// The newly created chat session. + /// Chat session created successfully. + /// Validation error. + /// Authentication required. + /// Rate limit exceeded. [HttpPost("sessions")] [EnableRateLimiting(RateLimitingPolicyNames.HotPathPerUser)] + [ProducesResponseType(typeof(ChatSessionDto), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)] public async Task CreateSession([FromBody] CreateChatSessionDto dto, CancellationToken ct = default) { if (!TryGetCurrentUserId(out var userId, out var errorResult)) @@ -36,7 +55,16 @@ public async Task CreateSession([FromBody] CreateChatSessionDto d : result.ToErrorActionResult(); } + /// + /// List all chat sessions for the current user. + /// + /// Cancellation token. + /// A list of chat sessions with recent messages. + /// Returns the user's chat sessions. + /// Authentication required. [HttpGet("sessions")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] public async Task GetMySessions(CancellationToken ct = default) { if (!TryGetCurrentUserId(out var userId, out var errorResult)) @@ -46,8 +74,18 @@ public async Task GetMySessions(CancellationToken ct = default) return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); } + /// + /// Check the health and availability of the configured LLM provider. + /// + /// When true, sends a lightweight test request to the provider. + /// Cancellation token. + /// Provider health status including name, model, and availability. + /// Returns provider health information. + /// Authentication required. [HttpGet("health")] [EnableRateLimiting(RateLimitingPolicyNames.HotPathPerUser)] + [ProducesResponseType(typeof(ChatProviderHealthDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] public async Task GetProviderHealth([FromQuery] bool probe = false, CancellationToken ct = default) { if (!TryGetCurrentUserId(out _, out var errorResult)) @@ -56,7 +94,19 @@ public async Task GetProviderHealth([FromQuery] bool probe = fals return Ok(await _chatService.GetProviderHealthAsync(probe, ct)); } + /// + /// Get a chat session by ID, including recent messages. + /// + /// The chat session identifier. + /// Cancellation token. + /// The chat session with recent messages. + /// Returns the chat session. + /// Authentication required. + /// Chat session not found. [HttpGet("sessions/{id}")] + [ProducesResponseType(typeof(ChatSessionDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] public async Task GetSession(Guid id, CancellationToken ct = default) { if (!TryGetCurrentUserId(out var userId, out var errorResult)) @@ -66,8 +116,24 @@ public async Task GetSession(Guid id, CancellationToken ct = defa return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); } + /// + /// Send a message in a chat session. The LLM responds and may generate + /// automation proposals when RequestProposal is true. + /// + /// The chat session identifier. + /// Message content and optional proposal request flag. + /// Cancellation token. + /// The assistant's response message. + /// Message sent and response received. + /// Authentication required. + /// Chat session not found. + /// Rate limit exceeded. [HttpPost("sessions/{id}/messages")] [EnableRateLimiting(RateLimitingPolicyNames.HotPathPerUser)] + [ProducesResponseType(typeof(ChatMessageDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)] public async Task SendMessage(Guid id, [FromBody] SendChatMessageDto dto, CancellationToken ct = default) { if (!TryGetCurrentUserId(out var userId, out var errorResult)) @@ -77,8 +143,18 @@ public async Task SendMessage(Guid id, [FromBody] SendChatMessage return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); } + /// + /// Stream the LLM response for a chat session via Server-Sent Events (SSE). + /// Events are emitted as "message.delta" (partial tokens) and "message.complete" (final). + /// + /// The chat session identifier. + /// Cancellation token. + /// SSE stream of response tokens. + /// Authentication required. + /// Chat session not found. [HttpGet("sessions/{id}/stream")] [EnableRateLimiting(RateLimitingPolicyNames.HotPathPerUser)] + [Produces("text/event-stream")] public async Task GetStream(Guid id, CancellationToken ct = default) { if (!TryGetCurrentUserId(out var userId, out var errorResult)) diff --git a/backend/src/Taskdeck.Api/Controllers/OutboundWebhooksController.cs b/backend/src/Taskdeck.Api/Controllers/OutboundWebhooksController.cs index ec03509b5..05fe644e9 100644 --- a/backend/src/Taskdeck.Api/Controllers/OutboundWebhooksController.cs +++ b/backend/src/Taskdeck.Api/Controllers/OutboundWebhooksController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Taskdeck.Api.Contracts; using Taskdeck.Api.Extensions; using Taskdeck.Application.DTOs; using Taskdeck.Application.Interfaces; @@ -10,9 +11,15 @@ namespace Taskdeck.Api.Controllers; +/// +/// Board-scoped outbound webhook subscriptions. Webhooks deliver signed event +/// payloads to external endpoints when board mutations occur. Consumers verify +/// delivery authenticity using HMAC-SHA256 signatures. +/// [ApiController] [Authorize] [Route("api/boards/{boardId}/webhooks")] +[Produces("application/json")] public class OutboundWebhooksController : AuthenticatedControllerBase { private readonly IOutboundWebhookService _outboundWebhookService; @@ -28,7 +35,19 @@ public OutboundWebhooksController( _authorizationService = authorizationService; } + /// + /// List all webhook subscriptions for a board. + /// + /// The board identifier. + /// Cancellation token. + /// A list of webhook subscriptions. + /// Returns the webhook subscriptions. + /// Authentication required. + /// User does not have permission to manage webhooks on this board. [HttpGet] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)] public async Task ListSubscriptions( Guid boardId, CancellationToken cancellationToken = default) @@ -54,7 +73,23 @@ public async Task ListSubscriptions( return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); } + /// + /// Create a new webhook subscription for a board. Returns the subscription + /// along with its signing secret (shown only once). + /// + /// The board identifier. + /// Subscription parameters: endpoint URL and optional event type filters. + /// Cancellation token. + /// The created subscription with its signing secret. + /// Subscription created. The signing secret is included in the response and will not be shown again. + /// Validation error (e.g., invalid endpoint URL). + /// Authentication required. + /// User does not have permission to manage webhooks on this board. [HttpPost] + [ProducesResponseType(typeof(OutboundWebhookSubscriptionSecretDto), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)] public async Task CreateSubscription( Guid boardId, [FromBody] CreateOutboundWebhookSubscriptionDto? dto, @@ -90,7 +125,23 @@ public async Task CreateSubscription( : result.ToErrorActionResult(); } + /// + /// Rotate the signing secret for a webhook subscription. The new secret is + /// returned in the response and will not be shown again. + /// + /// The board identifier. + /// The subscription identifier. + /// Cancellation token. + /// The subscription with the new signing secret. + /// Secret rotated successfully. + /// Authentication required. + /// User does not have permission to manage webhooks on this board. + /// Subscription not found. [HttpPost("{subscriptionId:guid}/rotate-secret")] + [ProducesResponseType(typeof(OutboundWebhookSubscriptionSecretDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] public async Task RotateSecret( Guid boardId, Guid subscriptionId, @@ -121,7 +172,21 @@ public async Task RotateSecret( return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); } + /// + /// Revoke (deactivate) a webhook subscription. Pending deliveries will be cancelled. + /// + /// The board identifier. + /// The subscription identifier. + /// Cancellation token. + /// Subscription revoked successfully. + /// Authentication required. + /// User does not have permission to manage webhooks on this board. + /// Subscription not found. [HttpDelete("{subscriptionId:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] public async Task RevokeSubscription( Guid boardId, Guid subscriptionId, From aafdbe6c18c3401d0557a91ff9401b5f8cf93663 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 30 Mar 2026 22:47:49 +0100 Subject: [PATCH 4/8] Add developer portal quickstart, auth, and boards API docs Create docs/api/ with generated-from-source integration documentation: - QUICKSTART.md: zero-to-authenticated-call in 5 minutes - AUTHENTICATION.md: JWT flow, GitHub OAuth, rate limiting - BOARDS.md: full CRUD reference for boards, columns, cards, and labels Part of #99 --- docs/api/AUTHENTICATION.md | 170 +++++++++++++++++ docs/api/BOARDS.md | 378 +++++++++++++++++++++++++++++++++++++ docs/api/QUICKSTART.md | 139 ++++++++++++++ 3 files changed, 687 insertions(+) create mode 100644 docs/api/AUTHENTICATION.md create mode 100644 docs/api/BOARDS.md create mode 100644 docs/api/QUICKSTART.md diff --git a/docs/api/AUTHENTICATION.md b/docs/api/AUTHENTICATION.md new file mode 100644 index 000000000..2e8944f82 --- /dev/null +++ b/docs/api/AUTHENTICATION.md @@ -0,0 +1,170 @@ +# Authentication + +Taskdeck uses JWT Bearer tokens for API authentication. All endpoints except `/api/auth/*` and `/api/health` require a valid token. + +## Obtaining a token + +### Register a new account + +```bash +curl -s -X POST http://localhost:5000/api/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "username": "alice", + "email": "alice@example.com", + "password": "SecureP@ss1" + }' +``` + +Response (`200 OK`): + +```json +{ + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "user": { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "username": "alice", + "email": "alice@example.com", + "defaultRole": "Editor", + "isActive": true, + "createdAt": "2026-03-30T10:00:00Z", + "updatedAt": "2026-03-30T10:00:00Z" + } +} +``` + +### Login with existing credentials + +```bash +curl -s -X POST http://localhost:5000/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "usernameOrEmail": "alice", + "password": "SecureP@ss1" + }' +``` + +The response shape is identical to registration. + +### Failed login + +```json +{ + "errorCode": "AuthenticationFailed", + "message": "Invalid username/email or password" +} +``` + +HTTP status: `401 Unauthorized` + +## Using the token + +Include the token in the `Authorization` header with the `Bearer` scheme: + +```bash +curl -s http://localhost:5000/api/boards \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..." +``` + +### Missing or invalid token + +If the token is missing, expired, or invalid, the API returns: + +```json +{ + "errorCode": "AuthenticationFailed", + "message": "Authentication is required to access this resource" +} +``` + +HTTP status: `401 Unauthorized` + +### Insufficient permissions + +If the token is valid but the user lacks permission for the requested resource: + +```json +{ + "errorCode": "Forbidden", + "message": "You do not have permission to access this resource" +} +``` + +HTTP status: `403 Forbidden` + +## JWT claims + +The JWT payload contains these claims: + +| Claim | Description | +|-------|-------------| +| `sub` | User ID (GUID) | +| `unique_name` | Username | +| `email` | Email address | +| `role` | Default role (e.g., `Editor`, `Admin`) | +| `exp` | Expiration timestamp (Unix epoch) | +| `iss` | Issuer (`Taskdeck`) | +| `aud` | Audience (`Taskdeck`) | + +## Change password + +```bash +curl -s -X POST http://localhost:5000/api/auth/change-password \ + -H "Content-Type: application/json" \ + -d '{ + "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "currentPassword": "SecureP@ss1", + "newPassword": "NewSecureP@ss2" + }' +``` + +Response: `204 No Content` on success. + +## GitHub OAuth (optional) + +When the Taskdeck instance has GitHub OAuth configured, users can authenticate via GitHub. + +### Check provider availability + +```bash +curl -s http://localhost:5000/api/auth/providers +``` + +```json +{ + "gitHub": true +} +``` + +### OAuth flow + +1. **Redirect the user** to `GET /api/auth/github/login?returnUrl=/` -- this initiates the GitHub OAuth handshake. +2. **GitHub callback** -- after authorization, the user is redirected back with a short-lived `oauth_code` query parameter. +3. **Exchange the code** for a JWT: + +```bash +curl -s -X POST http://localhost:5000/api/auth/github/exchange \ + -H "Content-Type: application/json" \ + -d '{"code": "the-oauth-code-from-redirect"}' +``` + +Response is the same `AuthResultDto` shape (token + user). + +The authorization code is single-use and expires after 60 seconds. + +## Rate limiting + +Auth endpoints are rate-limited per IP address. When the limit is exceeded: + +```json +{ + "errorCode": "TooManyRequests", + "message": "Rate limit exceeded" +} +``` + +HTTP status: `429 Too Many Requests` + +## Request correlation + +Every API response includes an `X-Request-Id` header. Include this ID when reporting issues for server-side log correlation. diff --git a/docs/api/BOARDS.md b/docs/api/BOARDS.md new file mode 100644 index 000000000..9e361544b --- /dev/null +++ b/docs/api/BOARDS.md @@ -0,0 +1,378 @@ +# Boards, Columns, Cards, and Labels + +All board-related endpoints require authentication via JWT Bearer token. + +## Boards + +### List boards + +```bash +curl -s http://localhost:5000/api/boards \ + -H "Authorization: Bearer $TOKEN" +``` + +Query parameters: + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `search` | string | - | Filter boards by name substring | +| `includeArchived` | bool | `false` | Include archived boards | + +Response (`200 OK`): + +```json +[ + { + "id": "f5e6d7c8-1234-5678-abcd-ef0123456789", + "name": "Sprint 42", + "description": "Current sprint board", + "isArchived": false, + "createdAt": "2026-03-28T09:00:00Z", + "updatedAt": "2026-03-29T14:30:00Z" + } +] +``` + +### Get board detail + +```bash +curl -s http://localhost:5000/api/boards/$BOARD_ID \ + -H "Authorization: Bearer $TOKEN" +``` + +Returns the board with its columns included: + +```json +{ + "id": "f5e6d7c8-...", + "name": "Sprint 42", + "description": "Current sprint board", + "isArchived": false, + "createdAt": "2026-03-28T09:00:00Z", + "updatedAt": "2026-03-29T14:30:00Z", + "columns": [ + { + "id": "a1b2c3d4-...", + "boardId": "f5e6d7c8-...", + "name": "To Do", + "position": 0, + "wipLimit": null, + "cardCount": 3, + "createdAt": "2026-03-28T09:01:00Z", + "updatedAt": "2026-03-28T09:01:00Z" + } + ] +} +``` + +### Create a board + +```bash +curl -s -X POST http://localhost:5000/api/boards \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "name": "New Board", + "description": "Optional description" + }' +``` + +Response: `201 Created` with the board object. + +### Update a board + +```bash +curl -s -X PUT http://localhost:5000/api/boards/$BOARD_ID \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "name": "Updated Name", + "isArchived": false + }' +``` + +All fields are optional -- only provided fields are updated. + +### Delete a board + +```bash +curl -s -X DELETE http://localhost:5000/api/boards/$BOARD_ID \ + -H "Authorization: Bearer $TOKEN" +``` + +Response: `204 No Content` + +--- + +## Columns + +Columns live under a board and define workflow stages. + +**Base URL:** `api/boards/{boardId}/columns` + +### List columns + +```bash +curl -s "http://localhost:5000/api/boards/$BOARD_ID/columns" \ + -H "Authorization: Bearer $TOKEN" +``` + +Response (`200 OK`): + +```json +[ + { + "id": "a1b2c3d4-...", + "boardId": "f5e6d7c8-...", + "name": "To Do", + "position": 0, + "wipLimit": null, + "cardCount": 3, + "createdAt": "2026-03-28T09:01:00Z", + "updatedAt": "2026-03-28T09:01:00Z" + }, + { + "id": "b2c3d4e5-...", + "boardId": "f5e6d7c8-...", + "name": "In Progress", + "position": 1, + "wipLimit": 5, + "cardCount": 2, + "createdAt": "2026-03-28T09:01:00Z", + "updatedAt": "2026-03-28T09:01:00Z" + } +] +``` + +### Create a column + +```bash +curl -s -X POST "http://localhost:5000/api/boards/$BOARD_ID/columns" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "boardId": "'$BOARD_ID'", + "name": "Done", + "position": 2, + "wipLimit": null + }' +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `boardId` | GUID | yes | Overridden by route parameter | +| `name` | string | yes | Column display name | +| `position` | int | no | Position index (auto-assigned if omitted) | +| `wipLimit` | int? | no | Maximum cards allowed in this column | + +### Update a column + +```bash +curl -s -X PATCH "http://localhost:5000/api/boards/$BOARD_ID/columns/$COLUMN_ID" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"wipLimit": 3}' +``` + +### Delete a column + +```bash +curl -s -X DELETE "http://localhost:5000/api/boards/$BOARD_ID/columns/$COLUMN_ID" \ + -H "Authorization: Bearer $TOKEN" +``` + +Response: `204 No Content`. The column must be empty (no cards). + +### Reorder columns + +```bash +curl -s -X POST "http://localhost:5000/api/boards/$BOARD_ID/columns/reorder" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "columnIds": [ + "b2c3d4e5-...", + "a1b2c3d4-...", + "c3d4e5f6-..." + ] + }' +``` + +All column IDs for the board must be included in the desired order. + +--- + +## Cards + +Cards are individual work items within a column. + +**Base URL:** `api/boards/{boardId}/cards` + +### Search cards + +```bash +curl -s "http://localhost:5000/api/boards/$BOARD_ID/cards?search=dark+mode" \ + -H "Authorization: Bearer $TOKEN" +``` + +Query parameters: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `search` | string | Text search on title/description | +| `labelId` | GUID | Filter by label | +| `columnId` | GUID | Filter by column | + +Response (`200 OK`): + +```json +[ + { + "id": "d4e5f6a7-...", + "boardId": "f5e6d7c8-...", + "columnId": "a1b2c3d4-...", + "title": "Add dark mode", + "description": "Implement dark theme toggle in settings", + "dueDate": "2026-04-15T00:00:00Z", + "isBlocked": false, + "blockReason": null, + "position": 0, + "labels": [ + { + "id": "e5f6a7b8-...", + "boardId": "f5e6d7c8-...", + "name": "enhancement", + "colorHex": "#10B981", + "createdAt": "2026-03-28T09:02:00Z", + "updatedAt": "2026-03-28T09:02:00Z" + } + ], + "createdAt": "2026-03-29T10:00:00Z", + "updatedAt": "2026-03-29T10:00:00Z" + } +] +``` + +### Create a card + +```bash +curl -s -X POST "http://localhost:5000/api/boards/$BOARD_ID/cards" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "boardId": "'$BOARD_ID'", + "columnId": "'$COLUMN_ID'", + "title": "Implement search", + "description": "Full-text search across cards", + "dueDate": "2026-04-10T00:00:00Z", + "labelIds": ["e5f6a7b8-..."] + }' +``` + +Response: `201 Created` with the card object. + +### Update a card + +Uses `PATCH` with optional fields. Supports optimistic concurrency: + +```bash +curl -s -X PATCH "http://localhost:5000/api/boards/$BOARD_ID/cards/$CARD_ID" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "title": "Updated title", + "isBlocked": true, + "blockReason": "Waiting for design review", + "expectedUpdatedAt": "2026-03-29T10:00:00Z" + }' +``` + +If `expectedUpdatedAt` is provided and the card has been modified since that timestamp, the API returns `409 Conflict`. + +### Move a card + +```bash +curl -s -X POST "http://localhost:5000/api/boards/$BOARD_ID/cards/$CARD_ID/move" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "targetColumnId": "b2c3d4e5-...", + "targetPosition": 0 + }' +``` + +Returns `400 Bad Request` if the target column's WIP limit would be exceeded. + +### Delete a card + +```bash +curl -s -X DELETE "http://localhost:5000/api/boards/$BOARD_ID/cards/$CARD_ID" \ + -H "Authorization: Bearer $TOKEN" +``` + +Response: `204 No Content` + +### Card capture provenance + +View the link between a card and the capture item/proposal that created it: + +```bash +curl -s "http://localhost:5000/api/boards/$BOARD_ID/cards/$CARD_ID/provenance" \ + -H "Authorization: Bearer $TOKEN" +``` + +```json +{ + "cardId": "d4e5f6a7-...", + "captureItemId": "11223344-...", + "proposalId": "55667788-...", + "proposalStatus": "Approved", + "triageRunId": "99aabbcc-..." +} +``` + +--- + +## Labels + +Labels are board-scoped color-coded tags for cards. + +**Base URL:** `api/boards/{boardId}/labels` + +### List labels + +```bash +curl -s "http://localhost:5000/api/boards/$BOARD_ID/labels" \ + -H "Authorization: Bearer $TOKEN" +``` + +### Create a label + +```bash +curl -s -X POST "http://localhost:5000/api/boards/$BOARD_ID/labels" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "boardId": "'$BOARD_ID'", + "name": "bug", + "colorHex": "#EF4444" + }' +``` + +### Update a label + +```bash +curl -s -X PATCH "http://localhost:5000/api/boards/$BOARD_ID/labels/$LABEL_ID" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"name": "critical-bug", "colorHex": "#DC2626"}' +``` + +### Delete a label + +```bash +curl -s -X DELETE "http://localhost:5000/api/boards/$BOARD_ID/labels/$LABEL_ID" \ + -H "Authorization: Bearer $TOKEN" +``` + +Response: `204 No Content` diff --git a/docs/api/QUICKSTART.md b/docs/api/QUICKSTART.md new file mode 100644 index 000000000..cfb3d541b --- /dev/null +++ b/docs/api/QUICKSTART.md @@ -0,0 +1,139 @@ +# Taskdeck API Developer Quickstart + +This guide gets you from zero to making authenticated API calls in under five minutes. + +## Prerequisites + +- .NET 8 SDK +- Git + +## 1. Start the API + +```bash +git clone https://github.com/Chris0Jeky/Taskdeck.git +cd Taskdeck +dotnet run --project backend/src/Taskdeck.Api/Taskdeck.Api.csproj +``` + +The API starts on `http://localhost:5000` by default. Swagger UI is available at `http://localhost:5000/swagger`. + +## 2. Register a user + +```bash +curl -s -X POST http://localhost:5000/api/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "username": "dev", + "email": "dev@example.com", + "password": "P@ssw0rd123" + }' +``` + +Response: + +```json +{ + "token": "eyJhbGciOiJIUzI1NiIs...", + "user": { + "id": "a1b2c3d4-...", + "username": "dev", + "email": "dev@example.com", + "defaultRole": "Editor", + "isActive": true, + "createdAt": "2026-03-30T12:00:00Z", + "updatedAt": "2026-03-30T12:00:00Z" + } +} +``` + +Save the `token` value for subsequent requests. + +## 3. Create a board + +```bash +export TOKEN="eyJhbGciOiJIUzI1NiIs..." + +curl -s -X POST http://localhost:5000/api/boards \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "name": "My First Board", + "description": "Getting started with Taskdeck" + }' +``` + +Response: + +```json +{ + "id": "f5e6d7c8-...", + "name": "My First Board", + "description": "Getting started with Taskdeck", + "isArchived": false, + "createdAt": "2026-03-30T12:01:00Z", + "updatedAt": "2026-03-30T12:01:00Z" +} +``` + +## 4. Add columns + +```bash +export BOARD_ID="f5e6d7c8-..." + +curl -s -X POST "http://localhost:5000/api/boards/$BOARD_ID/columns" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"boardId": "'$BOARD_ID'", "name": "To Do", "position": 0}' +``` + +## 5. Create a card + +```bash +export COLUMN_ID="..." # from the column creation response + +curl -s -X POST "http://localhost:5000/api/boards/$BOARD_ID/cards" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "boardId": "'$BOARD_ID'", + "columnId": "'$COLUMN_ID'", + "title": "My first task", + "description": "Created via the API" + }' +``` + +## 6. Quick-capture an item + +The capture pipeline lets you throw in raw text that gets triaged into a proposal: + +```bash +curl -s -X POST http://localhost:5000/api/capture/items \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "boardId": "'$BOARD_ID'", + "text": "Add dark mode support to the settings page" + }' +``` + +Then enqueue it for triage (generates an automation proposal): + +```bash +export CAPTURE_ID="..." # from the capture creation response + +curl -s -X POST "http://localhost:5000/api/capture/items/$CAPTURE_ID/triage" \ + -H "Authorization: Bearer $TOKEN" +``` + +## Interactive API docs + +Browse all endpoints interactively at **http://localhost:5000/swagger**. The Swagger UI supports "Try it out" with the JWT Bearer token for authenticated requests. + +## Next steps + +- [Authentication guide](AUTHENTICATION.md) -- JWT flow, token refresh, GitHub OAuth +- [Boards, Columns, Cards, Labels](BOARDS.md) -- full CRUD reference with examples +- [Capture pipeline](CAPTURE.md) -- capture, triage, and proposal flow +- [Chat API](CHAT.md) -- LLM-powered chat sessions and streaming +- [Webhooks](WEBHOOKS.md) -- outbound webhook setup and signature verification +- [Error contracts](ERROR_CONTRACTS.md) -- error codes and HTTP status mapping From de67130fb58f2a349934504e02013f7fa82217ec Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 30 Mar 2026 22:47:55 +0100 Subject: [PATCH 5/8] Add capture, chat, webhooks, and error contract API docs - CAPTURE.md: capture pipeline flow with status lifecycle - CHAT.md: LLM chat sessions, streaming, provider health - WEBHOOKS.md: subscription management, payload format, HMAC-SHA256 signature verification with Node.js/Python/C# examples - ERROR_CONTRACTS.md: error codes, HTTP status mapping, retry strategy Part of #99 --- docs/api/CAPTURE.md | 134 ++++++++++++++++++++++++ docs/api/CHAT.md | 161 +++++++++++++++++++++++++++++ docs/api/ERROR_CONTRACTS.md | 119 +++++++++++++++++++++ docs/api/WEBHOOKS.md | 199 ++++++++++++++++++++++++++++++++++++ 4 files changed, 613 insertions(+) create mode 100644 docs/api/CAPTURE.md create mode 100644 docs/api/CHAT.md create mode 100644 docs/api/ERROR_CONTRACTS.md create mode 100644 docs/api/WEBHOOKS.md diff --git a/docs/api/CAPTURE.md b/docs/api/CAPTURE.md new file mode 100644 index 000000000..2d3b8ba56 --- /dev/null +++ b/docs/api/CAPTURE.md @@ -0,0 +1,134 @@ +# Capture Pipeline + +The capture pipeline is Taskdeck's quick-capture system. Raw text is captured, then triaged by the LLM to generate automation proposals. Proposals must be explicitly approved by the user before any board mutations occur (review-first principle). + +## Capture flow + +``` +Create capture item --> Enqueue triage --> Proposal generated (async) + | | + v v + Ignore / Cancel Review queue (approve/reject) +``` + +## Create a capture item + +```bash +curl -s -X POST http://localhost:5000/api/capture/items \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "boardId": "f5e6d7c8-...", + "text": "Add pagination to the card list endpoint", + "source": "api", + "titleHint": "Card list pagination", + "externalRef": "JIRA-1234" + }' +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `boardId` | GUID | no | Target board (can be null for unscoped capture) | +| `text` | string | yes | Raw capture text | +| `source` | string | no | Origin identifier (e.g., `api`, `cli`, `browser`) | +| `titleHint` | string | no | Suggested card title for triage | +| `externalRef` | string | no | External reference ID for traceability | + +Response (`201 Created`): + +```json +{ + "id": "11223344-5566-7788-99aa-bbccddeeff00", + "userId": "3fa85f64-...", + "boardId": "f5e6d7c8-...", + "status": "Pending", + "source": "Api", + "rawText": "Add pagination to the card list endpoint", + "textExcerpt": "Add pagination to the card list endpoint", + "createdAt": "2026-03-30T12:00:00Z", + "processedAt": null, + "retryCount": 0, + "provenance": null +} +``` + +## List capture items + +```bash +curl -s "http://localhost:5000/api/capture/items?status=Pending&limit=20" \ + -H "Authorization: Bearer $TOKEN" +``` + +Query parameters: + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `status` | string | - | Filter by status: `Pending`, `Triaging`, `Processed`, `Ignored`, `Cancelled` | +| `boardId` | GUID | - | Filter by target board | +| `limit` | int | `50` | Maximum items to return | + +Response (`200 OK`): array of `CaptureItemSummaryDto`. + +## Get a capture item + +```bash +curl -s "http://localhost:5000/api/capture/items/$CAPTURE_ID" \ + -H "Authorization: Bearer $TOKEN" +``` + +## Enqueue for triage + +Sends the capture item to the LLM for proposal generation. The response is asynchronous -- the proposal will appear in the review queue when ready. + +```bash +curl -s -X POST "http://localhost:5000/api/capture/items/$CAPTURE_ID/triage" \ + -H "Authorization: Bearer $TOKEN" +``` + +Response (`202 Accepted`): + +```json +{ + "id": "11223344-...", + "status": "Triaging", + "alreadyTriaging": false +} +``` + +If the item is already being triaged, `alreadyTriaging` will be `true`. + +## Ignore a capture item + +Dismiss a capture item without processing: + +```bash +curl -s -X POST "http://localhost:5000/api/capture/items/$CAPTURE_ID/ignore" \ + -H "Authorization: Bearer $TOKEN" +``` + +Response: `204 No Content` + +## Cancel a capture item + +Cancel a capture item (e.g., submitted in error): + +```bash +curl -s -X POST "http://localhost:5000/api/capture/items/$CAPTURE_ID/cancel" \ + -H "Authorization: Bearer $TOKEN" +``` + +Response: `204 No Content` + +## Capture statuses + +| Status | Description | +|--------|-------------| +| `Pending` | Newly created, awaiting user action | +| `Triaging` | Sent to LLM for proposal generation | +| `Processed` | Triage complete, proposal generated | +| `Ignored` | Dismissed by user | +| `Cancelled` | Cancelled by user | + +## Rate limiting + +The create and triage endpoints are rate-limited per user to prevent abuse. Exceeding the limit returns `429 Too Many Requests`. diff --git a/docs/api/CHAT.md b/docs/api/CHAT.md new file mode 100644 index 000000000..0080e249b --- /dev/null +++ b/docs/api/CHAT.md @@ -0,0 +1,161 @@ +# Chat API + +The chat API provides LLM-powered conversational sessions that can generate automation proposals for board mutations. Chat sessions can optionally be scoped to a specific board. + +## Create a chat session + +```bash +curl -s -X POST http://localhost:5000/api/llm/chat/sessions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "title": "Sprint planning", + "boardId": "f5e6d7c8-..." + }' +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `title` | string | yes | Session display name | +| `boardId` | GUID | no | Scope the session to a board for context-aware proposals | + +Response (`201 Created`): + +```json +{ + "id": "aabbccdd-1122-3344-5566-778899aabbcc", + "userId": "3fa85f64-...", + "boardId": "f5e6d7c8-...", + "title": "Sprint planning", + "status": "Active", + "createdAt": "2026-03-30T12:00:00Z", + "updatedAt": "2026-03-30T12:00:00Z", + "recentMessages": [] +} +``` + +## List sessions + +```bash +curl -s http://localhost:5000/api/llm/chat/sessions \ + -H "Authorization: Bearer $TOKEN" +``` + +Returns all sessions for the current user with recent messages. + +## Get a session + +```bash +curl -s "http://localhost:5000/api/llm/chat/sessions/$SESSION_ID" \ + -H "Authorization: Bearer $TOKEN" +``` + +## Send a message + +```bash +curl -s -X POST "http://localhost:5000/api/llm/chat/sessions/$SESSION_ID/messages" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "content": "Create a card for adding dark mode support", + "requestProposal": true + }' +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `content` | string | yes | User message text | +| `requestProposal` | bool | no | When `true`, the LLM attempts to generate a board mutation proposal | + +Response (`200 OK`): + +```json +{ + "id": "eeff0011-...", + "sessionId": "aabbccdd-...", + "role": "Assistant", + "content": "I've created a proposal to add a 'Dark mode support' card...", + "messageType": "proposal", + "proposalId": "55667788-...", + "tokenUsage": 142, + "createdAt": "2026-03-30T12:01:00Z", + "degradedReason": null +} +``` + +### Message types + +| Type | Description | +|------|-------------| +| `text` | Regular conversational response | +| `proposal` | Response includes an automation proposal (check `proposalId`) | +| `degraded` | LLM provider returned a degraded response (check `degradedReason`) | + +### Degraded responses + +When the LLM provider is unavailable or encounters an error, the message includes a `degradedReason` explaining what happened: + +```json +{ + "role": "Assistant", + "content": "I'm currently unable to process this request.", + "messageType": "degraded", + "degradedReason": "LLM provider timeout after 30s" +} +``` + +## Streaming responses (SSE) + +For real-time token streaming, use the SSE endpoint: + +```bash +curl -s -N "http://localhost:5000/api/llm/chat/sessions/$SESSION_ID/stream" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Accept: text/event-stream" +``` + +Events: + +``` +event: message.delta +data: {"token": "I", "isComplete": false} + +event: message.delta +data: {"token": "'ve created", "isComplete": false} + +event: message.complete +data: {"token": "", "isComplete": true} +``` + +## Provider health + +Check the current LLM provider status: + +```bash +curl -s "http://localhost:5000/api/llm/chat/health" \ + -H "Authorization: Bearer $TOKEN" +``` + +Response: + +```json +{ + "isAvailable": true, + "providerName": "Mock", + "errorMessage": null, + "model": "mock-v1", + "isMock": true, + "isProbed": false +} +``` + +Add `?probe=true` to send a lightweight test request to the provider: + +```bash +curl -s "http://localhost:5000/api/llm/chat/health?probe=true" \ + -H "Authorization: Bearer $TOKEN" +``` + +## Provider configuration + +The default provider is `Mock` (deterministic responses for testing). Production providers (`OpenAI`, `Gemini`) are enabled via configuration. See `docs/platform/LLM_PROVIDER_SETUP_GUIDE.md` for setup details. diff --git a/docs/api/ERROR_CONTRACTS.md b/docs/api/ERROR_CONTRACTS.md new file mode 100644 index 000000000..a9ca26a32 --- /dev/null +++ b/docs/api/ERROR_CONTRACTS.md @@ -0,0 +1,119 @@ +# Error Contracts + +All Taskdeck API error responses follow a consistent shape. Understanding these contracts helps you build resilient integrations. + +## Error response format + +Every error response body has this structure: + +```json +{ + "errorCode": "NotFound", + "message": "Board not found or not accessible" +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `errorCode` | string | Machine-readable error identifier (see table below) | +| `message` | string | Human-readable description of the error | + +## Error codes + +| Error code | HTTP status | Description | +|------------|-------------|-------------| +| `NotFound` | 404 | The requested resource does not exist or is not accessible to the current user | +| `ValidationError` | 400 | Request body or query parameters failed validation | +| `WipLimitExceeded` | 400 | Card operation would exceed the column's WIP (work-in-progress) limit | +| `Conflict` | 409 | Resource was modified concurrently (e.g., stale `ExpectedUpdatedAt`) | +| `UnexpectedError` | 500 | An unexpected server-side error occurred | +| `Unauthorized` | 401 | The request lacks valid authentication credentials | +| `Forbidden` | 403 | The authenticated user does not have permission for this operation | +| `TooManyRequests` | 429 | Rate limit exceeded; retry after the indicated period | +| `AuthenticationFailed` | 401 | Login credentials are invalid or the token has expired | +| `InvalidOperation` | 400 | The operation is not valid in the current resource state | +| `LlmQuotaExceeded` | 429 | The user's LLM usage quota has been exceeded | +| `LlmKillSwitchActive` | 503 | The LLM provider has been disabled by an operator | +| `AbuseContainmentActive` | 403 | The user account is under abuse containment restrictions | + +## HTTP status code mapping + +| Status | Meaning | +|--------|---------| +| `200 OK` | Request succeeded, response body contains the result | +| `201 Created` | Resource created, `Location` header points to the new resource | +| `202 Accepted` | Request accepted for asynchronous processing | +| `204 No Content` | Request succeeded, no response body (e.g., delete operations) | +| `400 Bad Request` | Client error in the request body or parameters | +| `401 Unauthorized` | Authentication required or credentials invalid | +| `403 Forbidden` | Authenticated but insufficient permissions | +| `404 Not Found` | Resource not found or not accessible | +| `409 Conflict` | Concurrent modification conflict | +| `429 Too Many Requests` | Rate limit exceeded | +| `500 Internal Server Error` | Unexpected server error | +| `503 Service Unavailable` | Dependent service is unavailable (e.g., LLM kill switch) | + +## Request correlation + +Every response includes an `X-Request-Id` header. This ID is logged server-side and can be used for debugging and support requests: + +``` +X-Request-Id: 7f3a8b2c-1d4e-5f6a-b7c8-d9e0f1234567 +``` + +You can also supply your own correlation ID by sending the `X-Request-Id` header in the request. The server will echo it back. + +## Handling errors in integrations + +### Retry strategy + +| Error code | Retryable | Strategy | +|------------|-----------|----------| +| `TooManyRequests` | Yes | Respect `Retry-After` header, use exponential backoff | +| `LlmQuotaExceeded` | Yes (later) | Wait for quota reset period | +| `UnexpectedError` | Maybe | Retry with backoff, report if persistent | +| `Conflict` | Yes | Re-fetch the resource, resolve conflict, retry | +| `LlmKillSwitchActive` | No (temporary) | Wait for operator to re-enable the provider | +| All others | No | Fix the request and retry | + +### Example: error handling in JavaScript + +```javascript +async function taskdeckFetch(url, options) { + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + ...options?.headers, + }, + }); + + if (!response.ok) { + const error = await response.json(); + const requestId = response.headers.get('X-Request-Id'); + + switch (error.errorCode) { + case 'TooManyRequests': + // Back off and retry + const retryAfter = response.headers.get('Retry-After') || '5'; + await sleep(parseInt(retryAfter) * 1000); + return taskdeckFetch(url, options); + + case 'Conflict': + // Re-fetch and resolve + throw new ConflictError(error.message, requestId); + + case 'AuthenticationFailed': + // Token may be expired; re-authenticate + throw new AuthError(error.message, requestId); + + default: + throw new TaskdeckError(error.errorCode, error.message, requestId); + } + } + + if (response.status === 204) return null; + return response.json(); +} +``` diff --git a/docs/api/WEBHOOKS.md b/docs/api/WEBHOOKS.md new file mode 100644 index 000000000..42c27474d --- /dev/null +++ b/docs/api/WEBHOOKS.md @@ -0,0 +1,199 @@ +# Outbound Webhooks + +Taskdeck delivers signed event payloads to external endpoints when board mutations occur. Webhooks are board-scoped and support event type filtering. + +## Overview + +- Webhooks are created per-board by users with board management permission. +- Each subscription receives a unique signing secret (shown once at creation). +- Payloads are signed with HMAC-SHA256 for authenticity verification. +- Failed deliveries are retried with exponential backoff; permanently failed deliveries move to dead-letter state. + +## Create a webhook subscription + +```bash +curl -s -X POST "http://localhost:5000/api/boards/$BOARD_ID/webhooks" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "endpointUrl": "https://example.com/hooks/taskdeck", + "eventFilters": ["card.created", "card.moved", "card.deleted"] + }' +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `endpointUrl` | string | yes | HTTPS URL that receives webhook payloads | +| `eventFilters` | string[] | no | Event types to subscribe to (null = all events) | + +Response (`201 Created`): + +```json +{ + "subscription": { + "id": "11223344-...", + "boardId": "f5e6d7c8-...", + "endpointUrl": "https://example.com/hooks/taskdeck", + "eventFilters": ["card.created", "card.moved", "card.deleted"], + "isActive": true, + "createdAt": "2026-03-30T12:00:00Z", + "updatedAt": "2026-03-30T12:00:00Z", + "revokedAt": null, + "lastTriggeredAt": null + }, + "signingSecret": "whsec_a1b2c3d4e5f6..." +} +``` + +**Important:** The `signingSecret` is only returned at creation time and after secret rotation. Store it securely. + +## List subscriptions + +```bash +curl -s "http://localhost:5000/api/boards/$BOARD_ID/webhooks" \ + -H "Authorization: Bearer $TOKEN" +``` + +Returns an array of `OutboundWebhookSubscriptionDto` (without signing secrets). + +## Rotate signing secret + +If you suspect a secret has been compromised, rotate it: + +```bash +curl -s -X POST "http://localhost:5000/api/boards/$BOARD_ID/webhooks/$SUBSCRIPTION_ID/rotate-secret" \ + -H "Authorization: Bearer $TOKEN" +``` + +Returns the subscription with the new `signingSecret`. The old secret is immediately invalidated. + +## Revoke a subscription + +```bash +curl -s -X DELETE "http://localhost:5000/api/boards/$BOARD_ID/webhooks/$SUBSCRIPTION_ID" \ + -H "Authorization: Bearer $TOKEN" +``` + +Response: `204 No Content`. Pending deliveries are cancelled. + +## Event types + +| Event type | Description | +|------------|-------------| +| `card.created` | A card was created on the board | +| `card.updated` | A card's fields were modified | +| `card.moved` | A card was moved to a different column | +| `card.deleted` | A card was deleted | +| `column.created` | A column was added to the board | +| `column.updated` | A column was modified | +| `column.deleted` | A column was removed | +| `board.updated` | The board itself was modified | + +## Webhook payload format + +Payloads are delivered as JSON via HTTP POST: + +```json +{ + "id": "delivery-uuid", + "subscriptionId": "11223344-...", + "eventType": "card.created", + "boardId": "f5e6d7c8-...", + "timestamp": "2026-03-30T12:05:00Z", + "payload": { + "cardId": "d4e5f6a7-...", + "columnId": "a1b2c3d4-...", + "title": "New card title", + "createdBy": "3fa85f64-..." + } +} +``` + +## Signature verification + +Every delivery includes an `X-Taskdeck-Signature` header containing an HMAC-SHA256 signature of the request body. + +### Verification algorithm + +1. Read the raw request body as UTF-8 bytes. +2. Compute HMAC-SHA256 using your signing secret as the key. +3. Hex-encode the result. +4. Compare with the `X-Taskdeck-Signature` header value using constant-time comparison. + +### Example: Node.js verification + +```javascript +const crypto = require('crypto'); + +function verifySignature(rawBody, signature, secret) { + const expected = crypto + .createHmac('sha256', secret) + .update(rawBody, 'utf8') + .digest('hex'); + + return crypto.timingSafeEqual( + Buffer.from(signature, 'hex'), + Buffer.from(expected, 'hex') + ); +} + +// In your webhook handler: +app.post('/hooks/taskdeck', (req, res) => { + const signature = req.headers['x-taskdeck-signature']; + const rawBody = req.rawBody; // ensure you capture the raw body + + if (!verifySignature(rawBody, signature, process.env.TASKDECK_WEBHOOK_SECRET)) { + return res.status(401).send('Invalid signature'); + } + + const event = JSON.parse(rawBody); + console.log(`Received ${event.eventType} for board ${event.boardId}`); + res.status(200).send('OK'); +}); +``` + +### Example: Python verification + +```python +import hmac +import hashlib + +def verify_signature(raw_body: bytes, signature: str, secret: str) -> bool: + expected = hmac.new( + secret.encode('utf-8'), + raw_body, + hashlib.sha256 + ).hexdigest() + return hmac.compare_digest(expected, signature) +``` + +### Example: C# verification + +```csharp +using System.Security.Cryptography; +using System.Text; + +bool VerifySignature(string rawBody, string signature, string secret) +{ + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); + var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(rawBody)); + var expected = Convert.ToHexString(hash).ToLowerInvariant(); + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(expected), + Encoding.UTF8.GetBytes(signature)); +} +``` + +## Delivery behavior + +- Successful delivery: endpoint returns `2xx` status. +- Retry on failure: deliveries are retried with exponential backoff. +- Dead-letter: after exhausting retries, the delivery is moved to dead-letter state for manual inspection. +- Timeout: the webhook delivery worker waits up to 30 seconds for a response. + +## Security considerations + +- Always verify the `X-Taskdeck-Signature` header before processing payloads. +- Use HTTPS endpoints only. +- Rotate secrets periodically and immediately if compromise is suspected. +- Respond with `200 OK` quickly; process the event asynchronously if needed. From 70c9b19d20a0ce91aead82c8fffff0f5c86b505a Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 30 Mar 2026 22:48:19 +0100 Subject: [PATCH 6/8] Add developer portal CI workflow and local export script - reusable-developer-portal.yml: generates OpenAPI spec, builds Redoc static HTML, and bundles integration guides as a CI artifact - export-openapi-spec.sh: local convenience script to fetch the spec from a running API instance Part of #99 --- .../workflows/reusable-developer-portal.yml | 66 +++++++++++++++++++ scripts/export-openapi-spec.sh | 23 +++++++ 2 files changed, 89 insertions(+) create mode 100644 .github/workflows/reusable-developer-portal.yml create mode 100644 scripts/export-openapi-spec.sh diff --git a/.github/workflows/reusable-developer-portal.yml b/.github/workflows/reusable-developer-portal.yml new file mode 100644 index 000000000..22d687ed4 --- /dev/null +++ b/.github/workflows/reusable-developer-portal.yml @@ -0,0 +1,66 @@ +name: Reusable Developer Portal + +on: + workflow_call: + inputs: + dotnet-version: + description: .NET SDK version used for OpenAPI generation + required: false + default: "8.0.x" + type: string + node-version: + description: Node.js version used for Redoc CLI + required: false + default: "24.x" + type: string + +permissions: + contents: read + +env: + NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages + +jobs: + developer-portal: + name: Generate Developer Portal + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: ${{ inputs.dotnet-version }} + cache: true + cache-dependency-path: | + backend/Taskdeck.sln + backend/**/*.csproj + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node-version }} + + - name: Generate OpenAPI spec + shell: pwsh + run: ./scripts/ci/generate-openapi-artifact.ps1 -OutputPath "artifacts/openapi/taskdeck-api.json" + + - name: Generate Redoc static HTML + run: | + npx @redocly/cli build-docs artifacts/openapi/taskdeck-api.json \ + --output artifacts/developer-portal/index.html \ + --title "Taskdeck API Reference" + + - name: Copy integration docs + run: | + cp -r docs/api/ artifacts/developer-portal/guides/ + + - name: Upload developer portal artifact + if: always() + uses: actions/upload-artifact@v7 + with: + name: developer-portal + path: artifacts/developer-portal/ + if-no-files-found: warn diff --git a/scripts/export-openapi-spec.sh b/scripts/export-openapi-spec.sh new file mode 100644 index 000000000..f9c4c1378 --- /dev/null +++ b/scripts/export-openapi-spec.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Export the OpenAPI spec from a running Taskdeck API instance. +# Usage: ./scripts/export-openapi-spec.sh [output-path] [api-url] +# +# Defaults: +# output-path: artifacts/openapi/taskdeck-api.json +# api-url: http://localhost:5000 + +set -euo pipefail + +OUTPUT_PATH="${1:-artifacts/openapi/taskdeck-api.json}" +API_URL="${2:-http://localhost:5000}" +SWAGGER_URL="${API_URL}/swagger/v1/swagger.json" + +mkdir -p "$(dirname "$OUTPUT_PATH")" + +echo "Fetching OpenAPI spec from ${SWAGGER_URL}..." +curl -sf "$SWAGGER_URL" -o "$OUTPUT_PATH" + +echo "OpenAPI spec saved to ${OUTPUT_PATH}" +echo "" +echo "To generate static HTML docs with Redoc:" +echo " npx @redocly/cli build-docs ${OUTPUT_PATH} --output docs-output/index.html" From b841d73de763060ef1ec01071616aae86ea04c0e Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 30 Mar 2026 22:50:53 +0100 Subject: [PATCH 7/8] Fix webhook signature verification docs to match implementation The actual webhook delivery worker uses: - X-Taskdeck-Webhook-Signature header with sha256= prefix (not X-Taskdeck-Signature) - Canonical signing string: {unix_timestamp}.{payload} (not just raw body) - X-Taskdeck-Webhook-Timestamp header for replay protection - Additional delivery metadata headers (Delivery-Id, Subscription-Id, Event) Updated all verification examples (Node.js, Python, C#) accordingly. Part of #99 --- docs/api/WEBHOOKS.md | 62 ++++++++++++++++++++++++++++++++------------ 1 file changed, 45 insertions(+), 17 deletions(-) diff --git a/docs/api/WEBHOOKS.md b/docs/api/WEBHOOKS.md index 42c27474d..bc8d6b1db 100644 --- a/docs/api/WEBHOOKS.md +++ b/docs/api/WEBHOOKS.md @@ -109,26 +109,44 @@ Payloads are delivered as JSON via HTTP POST: } ``` +## Delivery headers + +Every webhook delivery includes these custom headers: + +| Header | Description | +|--------|-------------| +| `X-Taskdeck-Webhook-Delivery-Id` | Unique delivery identifier (GUID) | +| `X-Taskdeck-Webhook-Subscription-Id` | The subscription that triggered this delivery | +| `X-Taskdeck-Webhook-Event` | The event type (e.g., `card.created`) | +| `X-Taskdeck-Webhook-Timestamp` | Unix epoch seconds when the delivery was signed | +| `X-Taskdeck-Webhook-Signature` | `sha256={hex-encoded HMAC}` for payload verification | + ## Signature verification -Every delivery includes an `X-Taskdeck-Signature` header containing an HMAC-SHA256 signature of the request body. +The `X-Taskdeck-Webhook-Signature` header contains a `sha256=` prefixed HMAC-SHA256 signature. The canonical signing string is `{timestamp}.{payload}` where `{timestamp}` is the Unix epoch seconds from the `X-Taskdeck-Webhook-Timestamp` header and `{payload}` is the raw request body. ### Verification algorithm -1. Read the raw request body as UTF-8 bytes. -2. Compute HMAC-SHA256 using your signing secret as the key. -3. Hex-encode the result. -4. Compare with the `X-Taskdeck-Signature` header value using constant-time comparison. +1. Extract the `X-Taskdeck-Webhook-Timestamp` header value. +2. Extract the hex signature from `X-Taskdeck-Webhook-Signature` (strip the `sha256=` prefix). +3. Build the canonical string: `{timestamp}.{rawBody}`. +4. Compute HMAC-SHA256 of the canonical string using your signing secret as the key. +5. Hex-encode the result (lowercase). +6. Compare with the extracted signature using constant-time comparison. ### Example: Node.js verification ```javascript const crypto = require('crypto'); -function verifySignature(rawBody, signature, secret) { +function verifySignature(rawBody, timestampHeader, signatureHeader, secret) { + // Strip the "sha256=" prefix + const signature = signatureHeader.replace('sha256=', ''); + // Build the canonical signing string + const canonical = `${timestampHeader}.${rawBody}`; const expected = crypto .createHmac('sha256', secret) - .update(rawBody, 'utf8') + .update(canonical, 'utf8') .digest('hex'); return crypto.timingSafeEqual( @@ -139,15 +157,16 @@ function verifySignature(rawBody, signature, secret) { // In your webhook handler: app.post('/hooks/taskdeck', (req, res) => { - const signature = req.headers['x-taskdeck-signature']; + const signature = req.headers['x-taskdeck-webhook-signature']; + const timestamp = req.headers['x-taskdeck-webhook-timestamp']; const rawBody = req.rawBody; // ensure you capture the raw body - if (!verifySignature(rawBody, signature, process.env.TASKDECK_WEBHOOK_SECRET)) { + if (!verifySignature(rawBody, timestamp, signature, process.env.TASKDECK_WEBHOOK_SECRET)) { return res.status(401).send('Invalid signature'); } const event = JSON.parse(rawBody); - console.log(`Received ${event.eventType} for board ${event.boardId}`); + console.log(`Received ${req.headers['x-taskdeck-webhook-event']} event`); res.status(200).send('OK'); }); ``` @@ -158,10 +177,14 @@ app.post('/hooks/taskdeck', (req, res) => { import hmac import hashlib -def verify_signature(raw_body: bytes, signature: str, secret: str) -> bool: +def verify_signature(raw_body: str, timestamp: str, signature_header: str, secret: str) -> bool: + # Strip the "sha256=" prefix + signature = signature_header.removeprefix("sha256=") + # Build the canonical signing string + canonical = f"{timestamp}.{raw_body}" expected = hmac.new( secret.encode('utf-8'), - raw_body, + canonical.encode('utf-8'), hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, signature) @@ -173,10 +196,14 @@ def verify_signature(raw_body: bytes, signature: str, secret: str) -> bool: using System.Security.Cryptography; using System.Text; -bool VerifySignature(string rawBody, string signature, string secret) +bool VerifySignature(string rawBody, string timestamp, string signatureHeader, string secret) { + // Strip the "sha256=" prefix + var signature = signatureHeader.Replace("sha256=", ""); + // Build the canonical signing string + var canonical = $"{timestamp}.{rawBody}"; using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); - var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(rawBody)); + var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(canonical)); var expected = Convert.ToHexString(hash).ToLowerInvariant(); return CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(expected), @@ -189,11 +216,12 @@ bool VerifySignature(string rawBody, string signature, string secret) - Successful delivery: endpoint returns `2xx` status. - Retry on failure: deliveries are retried with exponential backoff. - Dead-letter: after exhausting retries, the delivery is moved to dead-letter state for manual inspection. -- Timeout: the webhook delivery worker waits up to 30 seconds for a response. +- Localhost endpoints: HTTP is only allowed for localhost endpoints when explicitly configured; all other endpoints must use HTTPS. ## Security considerations -- Always verify the `X-Taskdeck-Signature` header before processing payloads. -- Use HTTPS endpoints only. +- Always verify the `X-Taskdeck-Webhook-Signature` header before processing payloads. +- Check the `X-Taskdeck-Webhook-Timestamp` to reject stale deliveries (replay protection). +- Use HTTPS endpoints only (HTTP is only allowed for localhost in development). - Rotate secrets periodically and immediately if compromise is suspected. - Respond with `200 OK` quickly; process the event asynchronously if needed. From 81233a537e9c713ff96dd391d30026a14b0733db Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 30 Mar 2026 23:38:43 +0100 Subject: [PATCH 8/8] Fix Swashbuckle version and OpenApi v2.x compatibility Restore Swashbuckle.AspNetCore to 10.1.7 (matching main) and update OpenApi usage for v2.x API: namespace change from Microsoft.OpenApi.Models to Microsoft.OpenApi, use OpenApiSecuritySchemeReference, and Func-based AddSecurityRequirement. --- backend/src/Taskdeck.Api/Program.cs | 15 ++++----------- backend/src/Taskdeck.Api/Taskdeck.Api.csproj | 2 +- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/backend/src/Taskdeck.Api/Program.cs b/backend/src/Taskdeck.Api/Program.cs index 279897361..f016ea907 100644 --- a/backend/src/Taskdeck.Api/Program.cs +++ b/backend/src/Taskdeck.Api/Program.cs @@ -1,7 +1,7 @@ using System.Reflection; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; using Taskdeck.Api.Extensions; using Taskdeck.Api.FirstRun; using Taskdeck.Infrastructure; @@ -53,18 +53,11 @@ BearerFormat = "JWT" }); - options.AddSecurityRequirement(new OpenApiSecurityRequirement + options.AddSecurityRequirement(_ => new OpenApiSecurityRequirement { { - new OpenApiSecurityScheme - { - Reference = new OpenApiReference - { - Type = ReferenceType.SecurityScheme, - Id = "Bearer" - } - }, - Array.Empty() + new OpenApiSecuritySchemeReference("Bearer"), + new List() } }); diff --git a/backend/src/Taskdeck.Api/Taskdeck.Api.csproj b/backend/src/Taskdeck.Api/Taskdeck.Api.csproj index 17cf75cd6..99f41034f 100644 --- a/backend/src/Taskdeck.Api/Taskdeck.Api.csproj +++ b/backend/src/Taskdeck.Api/Taskdeck.Api.csproj @@ -29,7 +29,7 @@ - +