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/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/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/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/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/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/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)) 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, diff --git a/backend/src/Taskdeck.Api/Program.cs b/backend/src/Taskdeck.Api/Program.cs index e9321ebc3..f016ea907 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; using Taskdeck.Api.Extensions; using Taskdeck.Api.FirstRun; using Taskdeck.Infrastructure; @@ -21,7 +23,52 @@ 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 OpenApiSecuritySchemeReference("Bearer"), + new List() + } + }); + + // 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..99f41034f 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 @@ -27,7 +29,7 @@ - + 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/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/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 diff --git a/docs/api/WEBHOOKS.md b/docs/api/WEBHOOKS.md new file mode 100644 index 000000000..bc8d6b1db --- /dev/null +++ b/docs/api/WEBHOOKS.md @@ -0,0 +1,227 @@ +# 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-..." + } +} +``` + +## 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 + +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. 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, 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(canonical, '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-webhook-signature']; + const timestamp = req.headers['x-taskdeck-webhook-timestamp']; + const rawBody = req.rawBody; // ensure you capture the raw body + + 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 ${req.headers['x-taskdeck-webhook-event']} event`); + res.status(200).send('OK'); +}); +``` + +### Example: Python verification + +```python +import hmac +import hashlib + +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'), + canonical.encode('utf-8'), + 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 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(canonical)); + 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. +- 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-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. 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"