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"