Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Branch protection on `main`: no direct pushes, no force pushes, no branch deleti

- xUnit + FluentAssertions. Unit tests only — the integration suite was removed as outdated; do not add integration tests or a real-DB harness.
- Every new feature needs unit tests: commands, queries, validators, domain logic.
- Test accounts bypass email verification via `REVIEWER_TEST_EMAIL` / `QA_TEST_CODE` env vars — see `tests/CLAUDE.md`.
- Test accounts bypass email verification via the `TEST_ACCOUNTS` env var (`email:code` pairs) — see `tests/CLAUDE.md`.

## Deployment

Expand Down
4 changes: 2 additions & 2 deletions src/Orbit.Api/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Controllers, middleware, OpenAPI/Scalar, DI config, `Program.cs`. This project s
Default to `[Authorize]` at the controller class level. Override with `[AllowAnonymous]` on individual actions when truly public (e.g., `POST /api/auth/send-code`). The middleware pipeline rejects unauthenticated requests for `[Authorize]` endpoints with 401.

Exempt by construction:
- `GET /health` (HealthCheckController)
- `GET /health` (mapped via `MapHealthChecks` in `Extensions/WebApplicationExtensions.cs` — no controller)
- `POST /api/auth/send-code`, `verify-code`, `google` (AuthController)
- Stripe webhook (verified by signature, not JWT)

Expand All @@ -27,7 +27,7 @@ FluentValidation runs via the MediatR validation pipeline. Controllers do NOT ca

Dev-only. `BearerSecuritySchemeTransformer` adds the JWT bearer scheme. New endpoints show up automatically — annotate with `[ProducesResponseType(StatusCodes.Status200OK)]` etc. to document response shapes.

## Middleware (already wired in Program.cs)
## Middleware (already wired in `Extensions/WebApplicationExtensions.cs`)

- `SecurityHeadersMiddleware` — nosniff, DENY, referrer-policy, XSS headers on every response.
- `RequestCorrelationMiddleware` — injects correlation ID for log tracing.
Expand Down
74 changes: 46 additions & 28 deletions src/Orbit.Api/Controllers/AiController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using Orbit.Api.RateLimiting;
using Orbit.Application.Chat.Models;
using Orbit.Application.Common;
using Orbit.Domain.Common;
using Orbit.Domain.Interfaces;
using Orbit.Domain.Models;

Expand Down Expand Up @@ -178,7 +179,7 @@ await auditService.RecordAsync(new AgentAuditEntry(
Error: confirmation is null ? "pending_operation_not_found" : null), cancellationToken);

return confirmation is null
? NotFound(new { error = "Pending operation not found or expired." })
? NotFound(ErrorMessages.PendingOperationNotFound.ToErrorBody())
: Ok(new ConfirmPendingOperationResponse(
confirmation.PendingOperationId,
confirmation.ConfirmationToken,
Expand Down Expand Up @@ -216,9 +217,7 @@ await auditService.RecordAsync(new AgentAuditEntry(
TargetId: id.ToString(),
Error: result.IsSuccess ? null : result.Error), cancellationToken);

return result.IsSuccess
? Ok(result.Value)
: BadRequest(new { error = result.Error });
return result.ToPayGateAwareResult(v => Ok(v));
}

[HttpPost("pending-operations/{id:guid}/step-up/verify")]
Expand Down Expand Up @@ -253,9 +252,7 @@ await auditService.RecordAsync(new AgentAuditEntry(
TargetId: id.ToString(),
Error: result.IsSuccess ? null : result.Error), cancellationToken);

return result.IsSuccess
? Ok(result.Value)
: BadRequest(new { error = result.Error });
return result.ToPayGateAwareResult(v => Ok(v));
}

[HttpPost("pending-operations/{id:guid}/execute")]
Expand Down Expand Up @@ -285,7 +282,7 @@ await auditService.RecordAsync(new AgentAuditEntry(
TargetId: id.ToString(),
Error: "pending_operation_not_found"), cancellationToken);

return NotFound(new { error = "Pending operation not found or expired." });
return NotFound(ErrorMessages.PendingOperationNotFound.ToErrorBody());
}

var result = await operationExecutor.ExecuteAsync(new AgentExecuteOperationRequest(
Expand Down Expand Up @@ -319,42 +316,36 @@ public async Task<IActionResult> ResolveClarification(
if (!validation.IsValid)
{
var firstError = validation.Errors[0];
await RecordResolveAuditAsync(
return await DenyResolveClarificationAsync(
userId,
authMethod,
operationId,
AgentPolicyDecisionStatus.Denied,
AgentOperationStatus.Failed,
$"invalid_clarification_value:{firstError.PropertyName}",
BadRequest(new AppError(firstError.ErrorCode ?? ErrorCodes.ValidationError, firstError.ErrorMessage).ToErrorBody()),
cancellationToken);
return BadRequest(new { error = firstError.ErrorMessage });
}

var pending = await pendingClarificationStore.GetForResolutionAsync(operationId, userId, cancellationToken);
if (pending is null)
{
await RecordResolveAuditAsync(
return await DenyResolveClarificationAsync(
userId,
authMethod,
operationId,
AgentPolicyDecisionStatus.Denied,
AgentOperationStatus.Failed,
"clarification_not_found",
NotFound(ErrorMessages.ClarificationNotFound.ToErrorBody()),
cancellationToken);
return NotFound(new { error = ErrorMessages.ClarificationNotFound });
}

if (!pending.AllowedValues.Contains(body.Value, StringComparer.Ordinal))
{
await RecordResolveAuditAsync(
return await DenyResolveClarificationAsync(
userId,
authMethod,
operationId,
AgentPolicyDecisionStatus.Denied,
AgentOperationStatus.Failed,
"clarification_value_not_offered",
BadRequest(ErrorMessages.ClarificationValueNotOffered.ToErrorBody()),
cancellationToken);
return BadRequest(new { error = ErrorMessages.ClarificationValueNotOffered });
}

JsonElement mergedArgs;
Expand All @@ -364,15 +355,13 @@ await RecordResolveAuditAsync(
}
catch (JsonException)
{
await RecordResolveAuditAsync(
return await DenyResolveClarificationAsync(
userId,
authMethod,
operationId,
AgentPolicyDecisionStatus.Denied,
AgentOperationStatus.Failed,
"invalid_clarification_value",
BadRequest(ErrorMessages.ClarificationValueNotJsonObject.ToErrorBody()),
cancellationToken);
return BadRequest(new { error = ErrorMessages.ClarificationValueNotJsonObject });
}

var claimed = await pendingClarificationStore.MarkResolvedAsync(operationId, userId, cancellationToken);
Expand All @@ -381,17 +370,46 @@ await RecordResolveAuditAsync(
var auditError = pending.ExpiresAtUtc <= DateTime.UtcNow
? "clarification_expired_mid_flight"
: "clarification_already_resolved";
await RecordResolveAuditAsync(
return await DenyResolveClarificationAsync(
userId,
authMethod,
operationId,
AgentPolicyDecisionStatus.Denied,
AgentOperationStatus.Failed,
auditError,
Conflict(ErrorMessages.ClarificationAlreadyResolved.ToErrorBody()),
cancellationToken);
return Conflict(new { error = ErrorMessages.ClarificationAlreadyResolved });
}

return await ExecuteResolvedClarificationAsync(userId, authMethod, operationId, pending, mergedArgs, cancellationToken);
}

private async Task<IActionResult> DenyResolveClarificationAsync(
Guid userId,
AgentAuthMethod authMethod,
Guid operationId,
string auditError,
IActionResult response,
CancellationToken cancellationToken)
{
await RecordResolveAuditAsync(
userId,
authMethod,
operationId,
AgentPolicyDecisionStatus.Denied,
AgentOperationStatus.Failed,
auditError,
cancellationToken);

return response;
}

private async Task<IActionResult> ExecuteResolvedClarificationAsync(
Guid userId,
AgentAuthMethod authMethod,
Guid operationId,
PendingClarificationData pending,
JsonElement mergedArgs,
CancellationToken cancellationToken)
{
var result = await operationExecutor.ExecuteAsync(new AgentExecuteOperationRequest(
userId,
pending.ToolName,
Expand Down
2 changes: 1 addition & 1 deletion src/Orbit.Api/Controllers/ApiKeysController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ public async Task<IActionResult> RevokeApiKey(Guid id, CancellationToken cancell
LogApiKeyRevoked(logger, id, HttpContext.GetUserId());
return NoContent();
}
return NotFound(new { error = result.Error });
return result.ToErrorResult(StatusCodes.Status404NotFound);
}

[LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "API key created {KeyId} by user {UserId}")]
Expand Down
16 changes: 9 additions & 7 deletions src/Orbit.Api/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public async Task<IActionResult> SendCode(
}

LogFailedToSendCode(logger, request.Email, result.Error, HttpContext.GetRequestId());
return BadRequest(new { error = result.Error });
return result.ToErrorResult();
}

[HttpPost("operations/send-code")]
Expand Down Expand Up @@ -94,7 +94,7 @@ public async Task<IActionResult> VerifyCode(
}

LogCodeVerificationFailed(logger, request.Email, result.Error, HttpContext.GetRequestId());
return Unauthorized(new { error = result.Error });
return result.ToErrorResult(StatusCodes.Status401Unauthorized);
}

[HttpPost("operations/verify-code")]
Expand Down Expand Up @@ -155,7 +155,7 @@ public async Task<IActionResult> GoogleAuth(
}

LogGoogleAuthFailed(logger, result.Error, HttpContext.GetRequestId());
return Unauthorized(new { error = result.Error });
return result.ToErrorResult(StatusCodes.Status401Unauthorized);
}

[HttpPost("operations/google")]
Expand Down Expand Up @@ -221,7 +221,7 @@ public async Task<IActionResult> Refresh(
}

LogSessionRefreshFailed(logger, result.Error, HttpContext.GetRequestId());
return Unauthorized(new { error = result.Error });
return result.ToErrorResult(StatusCodes.Status401Unauthorized);
}

[HttpPost("operations/refresh")]
Expand Down Expand Up @@ -274,7 +274,7 @@ public async Task<IActionResult> Logout(
}

LogSessionRevocationFailed(logger, result.Error, HttpContext.GetRequestId());
return Unauthorized(new { error = result.Error });
return result.ToErrorResult(StatusCodes.Status401Unauthorized);
}

[HttpPost("operations/logout")]
Expand Down Expand Up @@ -307,6 +307,7 @@ await RecordDirectAuthAuditAsync(

[Authorize]
[HttpPost("request-deletion")]
[DistributedRateLimit("auth")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
Expand All @@ -322,11 +323,12 @@ public async Task<IActionResult> RequestDeletion(CancellationToken cancellationT
}

LogDeletionRequestFailed(logger, HttpContext.GetUserId(), result.Error);
return BadRequest(new { error = result.Error });
return result.ToErrorResult();
}

[Authorize]
[HttpPost("confirm-deletion")]
[DistributedRateLimit("auth")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
Expand All @@ -344,7 +346,7 @@ public async Task<IActionResult> ConfirmDeletion(
}

LogDeletionConfirmationFailed(logger, HttpContext.GetUserId(), result.Error);
return BadRequest(new { error = result.Error });
return result.ToErrorResult();
}

[LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Verification code sent to {Email}. RequestId={RequestId}")]
Expand Down
3 changes: 2 additions & 1 deletion src/Orbit.Api/Controllers/CalendarController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Orbit.Api.Extensions;
using Orbit.Application.Calendar.Commands;
using Orbit.Application.Calendar.Queries;
using Orbit.Application.Common;

namespace Orbit.Api.Controllers;

Expand Down Expand Up @@ -72,7 +73,7 @@ public record SetAutoSyncRequest(bool? Enabled);
public async Task<IActionResult> SetAutoSync([FromBody] SetAutoSyncRequest request, CancellationToken cancellationToken)
{
if (request.Enabled is null)
return BadRequest(new { error = "Enabled is required." });
return BadRequest(ErrorMessages.AutoSyncEnabledRequired.ToErrorBody());

var command = new SetCalendarAutoSyncCommand(HttpContext.GetUserId(), request.Enabled.Value);
var result = await mediator.Send(command, cancellationToken);
Expand Down
25 changes: 14 additions & 11 deletions src/Orbit.Api/Controllers/ChatController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public async Task<IActionResult> ProcessChat(
[FromForm] string? confirmationToken = null)
{
if (string.IsNullOrWhiteSpace(message) || message.Length > AppConstants.MaxChatMessageLength)
return BadRequest(new { error = $"Message must be between 1 and {AppConstants.MaxChatMessageLength} characters" });
return BadRequest(ErrorMessages.MessageTooLong.Format(AppConstants.MaxChatMessageLength).ToErrorBody());

var (imageData, imageMimeType, imageError) = await ProcessImageAsync(image, cancellationToken);
if (imageError is not null)
Expand Down Expand Up @@ -82,7 +82,7 @@ public async Task<IActionResult> ProcessChatStream(
[FromForm] string? confirmationToken = null)
{
if (string.IsNullOrWhiteSpace(message) || message.Length > AppConstants.MaxChatMessageLength)
return BadRequest(new { error = $"Message must be between 1 and {AppConstants.MaxChatMessageLength} characters" });
return BadRequest(ErrorMessages.MessageTooLong.Format(AppConstants.MaxChatMessageLength).ToErrorBody());

var (imageData, imageMimeType, imageError) = await ProcessImageAsync(image, cancellationToken);
if (imageError is not null)
Expand Down Expand Up @@ -148,7 +148,10 @@ await WriteEventAsync(
{
LogChatStreamFailed(logger, ex);
await WriteEventAsync(
ChatStreamEvent.Failure(StatusCodes.Status500InternalServerError, "AI service temporarily unavailable"),
ChatStreamEvent.Failure(
StatusCodes.Status500InternalServerError,
ErrorMessages.AiUnavailable.Message,
ErrorMessages.AiUnavailable.Code),
cancellationToken);
}
}
Expand Down Expand Up @@ -179,7 +182,7 @@ private async Task WriteEventAsync(ChatStreamEvent streamEvent, CancellationToke
{
if (logger.IsEnabled(LogLevel.Warning))
LogChatImageValidationFailed(logger, validationResult.Error);
return (null, null, BadRequest(new { error = validationResult.Error }));
return (null, null, validationResult.ToErrorResult());
}

using var ms = new MemoryStream();
Expand All @@ -193,16 +196,16 @@ private async Task WriteEventAsync(ChatStreamEvent streamEvent, CancellationToke
return (null, null);

if (history.Length > AppConstants.MaxChatHistoryLength)
return (null, BadRequest(new { error = "Chat history too large" }));
return (null, BadRequest(ErrorMessages.ChatHistoryTooLarge.ToErrorBody()));

try
{
var chatHistory = JsonSerializer.Deserialize<List<ChatHistoryMessage>>(history, ChatHistoryJsonOptions);
if (chatHistory is null)
return (null, BadRequest(new { error = "Invalid chat history format" }));
return (null, BadRequest(ErrorMessages.InvalidChatHistory.ToErrorBody()));

if (chatHistory.Count > AppConstants.MaxChatHistoryMessages)
return (null, BadRequest(new { error = "Chat history too large" }));
return (null, BadRequest(ErrorMessages.ChatHistoryTooLarge.ToErrorBody()));

var normalizedHistory = new List<ChatHistoryMessage>(chatHistory.Count);

Expand All @@ -213,7 +216,7 @@ private async Task WriteEventAsync(ChatStreamEvent streamEvent, CancellationToke
string.IsNullOrWhiteSpace(item.Content) ||
item.Content.Length > AppConstants.MaxChatHistoryMessageLength)
{
return (null, BadRequest(new { error = "Invalid chat history format" }));
return (null, BadRequest(ErrorMessages.InvalidChatHistory.ToErrorBody()));
}

normalizedHistory.Add(new ChatHistoryMessage(normalizedRole, item.Content));
Expand All @@ -225,7 +228,7 @@ private async Task WriteEventAsync(ChatStreamEvent streamEvent, CancellationToke
{
if (logger.IsEnabled(LogLevel.Warning))
LogChatHistoryParseFailed(logger, ex, ex.Message);
return (null, BadRequest(new { error = "Invalid chat history format" }));
return (null, BadRequest(ErrorMessages.InvalidChatHistory.ToErrorBody()));
}
}

Expand All @@ -238,14 +241,14 @@ private async Task WriteEventAsync(ChatStreamEvent streamEvent, CancellationToke
{
var parsed = JsonSerializer.Deserialize<AgentClientContext>(clientContext, ChatHistoryJsonOptions);
return parsed is null
? (null, BadRequest(new { error = "Invalid client context format" }))
? (null, BadRequest(ErrorMessages.InvalidClientContext.ToErrorBody()))
: (parsed, null);
}
catch (JsonException ex)
{
if (logger.IsEnabled(LogLevel.Warning))
LogClientContextParseFailed(logger, ex, ex.Message);
return (null, BadRequest(new { error = "Invalid client context format" }));
return (null, BadRequest(ErrorMessages.InvalidClientContext.ToErrorBody()));
}
}

Expand Down
Loading
Loading