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
125 changes: 125 additions & 0 deletions src/main/java/com/aesop/server/config/AuthTokenFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package com.aesop.server.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Arrays;

/**
* Authentication filter for write-path endpoints (/api/v1/tracker/... POST).
*
* Read endpoints are open. Write endpoints require a static bearer token from
* the AESOP_SERVER_TOKEN environment variable. Token comparison uses
* constant-time comparison to prevent timing attacks.
*
* If AESOP_SERVER_TOKEN is not set, write endpoints return 503 with a
* "write-path disabled" message (fail-closed).
*/
@Component
public class AuthTokenFilter extends OncePerRequestFilter {
private static final String BEARER_PREFIX = "Bearer ";
private static final String TOKEN_HEADER = HttpHeaders.AUTHORIZATION;

private final String serverToken;

public AuthTokenFilter(@Value("${aesop.server-token:}") String serverToken) {
this.serverToken = serverToken;
}

@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
// Only validate auth on write endpoints (POST to /api/v1/tracker/...)
if (isWriteEndpoint(request)) {
if (!isConfigured()) {
// Write-path disabled; fail-closed
response.setStatus(HttpServletResponse.SC_SERVICE_UNAVAILABLE);
response.setContentType("application/json");
response.getWriter().write(
"{\"error\":\"write-path disabled: set AESOP_SERVER_TOKEN\"}"
);
return;
}

if (!validateToken(request)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"Invalid or missing authentication token\"}");
return;
}
}

filterChain.doFilter(request, response);
}

/**
* Check if this is a write endpoint that requires auth.
*/
private boolean isWriteEndpoint(HttpServletRequest request) {
String method = request.getMethod();
String path = request.getRequestURI();
return "POST".equals(method) && path.startsWith("/api/v1/tracker/");
}

/**
* Check if the server token is configured.
*/
private boolean isConfigured() {
return serverToken != null && !serverToken.isEmpty();
}

/**
* Validate the bearer token using constant-time comparison.
*/
private boolean validateToken(HttpServletRequest request) {
String authHeader = request.getHeader(TOKEN_HEADER);
if (authHeader == null) {
return false;
}

if (!authHeader.startsWith(BEARER_PREFIX)) {
return false;
}

String providedToken = authHeader.substring(BEARER_PREFIX.length());
return constantTimeEquals(providedToken, serverToken);
}

/**
* Constant-time string comparison to prevent timing attacks.
*/
private boolean constantTimeEquals(String a, String b) {
if (a == null || b == null) {
return a == b;
}

byte[] aBytes = a.getBytes();
byte[] bBytes = b.getBytes();

int result = 0;
result |= aBytes.length ^ bBytes.length;

int minLen = Math.min(aBytes.length, bBytes.length);
for (int i = 0; i < minLen; i++) {
result |= aBytes[i] ^ bBytes[i];
}

return result == 0;
}

@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
// Filter all requests; selectiveness is in doFilterInternal
return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package com.aesop.server.controller;

import com.aesop.server.dto.TrackerItem;
import com.aesop.server.dto.TrackerTransitionRequest;
import com.aesop.server.service.EventStreamService;
import com.aesop.server.service.InboxService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
* REST API for posting tracker mutations (write path).
* Validates transitions against current projected state, then appends
* the request to the orchestrator inbox for asynchronous application.
*
* All write operations return 202 Accepted (not 200) because the Python
* orchestrator applies them asynchronously on the next turn.
*/
@RestController
@RequestMapping("/api/v1/tracker")
public class TrackerTransitionController {
private final EventStreamService eventStreamService;
private final InboxService inboxService;

public TrackerTransitionController(
EventStreamService eventStreamService,
InboxService inboxService
) {
this.eventStreamService = eventStreamService;
this.inboxService = inboxService;
}

/**
* POST /api/v1/tracker/items/{id}/transitions
*
* Transition a tracker item to a new status.
* Validates the transition is legal based on current projected state.
* On accept, appends a structured request to the orchestrator inbox.
*
* Request body:
* {
* "targetStatus": "done|open|blocked|...",
* "note": "optional context"
* }
*
* Responses:
* - 202 Accepted: transition queued for asynchronous processing
* - 400 Bad Request: invalid transition or missing required fields
* - 401 Unauthorized: missing or invalid authentication token
* - 503 Service Unavailable: write-path disabled (AESOP_SERVER_TOKEN not set)
*/
@PostMapping("/items/{id}/transitions")
public ResponseEntity<?> postTransition(
@PathVariable String id,
@RequestBody TrackerTransitionRequest request
) {
// Validate request
if (request.targetStatus() == null || request.targetStatus().isEmpty()) {
return ResponseEntity.badRequest().body(
Map.of("error", "targetStatus is required")
);
}

// Get current item state via projection
TrackerItem currentItem = eventStreamService.getTrackerItemById(id);
if (currentItem == null) {
return ResponseEntity.badRequest().body(
Map.of("error", "Item not found: " + id)
);
}

// Validate transition is legal
if (!isValidTransition(currentItem.status(), request.targetStatus())) {
return ResponseEntity.badRequest().body(
Map.of(
"error", "Invalid transition from " + currentItem.status() +
" to " + request.targetStatus()
)
);
}

// Append to inbox for asynchronous processing
try {
inboxService.appendTrackerTransition(
id,
request.targetStatus(),
request.note()
);
} catch (RuntimeException e) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(
Map.of("error", e.getMessage())
);
}

// Return 202 Accepted with the appended request echoed back
Map<String, Object> response = new LinkedHashMap<>();
response.put("ts", java.time.Instant.now().toString());
response.put("source", "aesop-server");
response.put("kind", "tracker-transition");
response.put("itemId", id);
response.put("targetStatus", request.targetStatus());
if (request.note() != null && !request.note().isEmpty()) {
response.put("note", request.note());
}

return ResponseEntity.status(HttpStatus.ACCEPTED).body(response);
}

/**
* Validate a state transition.
* Simple state machine: any status is reachable from any other.
* More sophisticated validation (e.g., no double-done) can be added here.
*/
private boolean isValidTransition(String currentStatus, String targetStatus) {
// For now, allow any transition as long as they're different.
// The orchestrator can enforce stricter rules.
if (currentStatus == null || targetStatus == null) {
return false;
}
return !currentStatus.equals(targetStatus);
}
}
14 changes: 14 additions & 0 deletions src/main/java/com/aesop/server/dto/TrackerTransitionRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.aesop.server.dto;

import com.fasterxml.jackson.annotation.JsonInclude;

/**
* Request body for POST /api/v1/tracker/items/{id}/transitions.
* Represents a mutation to transition a tracker item to a new status.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record TrackerTransitionRequest(
String targetStatus,
String note
) {
}
14 changes: 14 additions & 0 deletions src/main/java/com/aesop/server/service/EventStoreReader.java
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,20 @@ public long getLastEventId() {
return 0;
}

/**
* Get a specific tracker item by ID from the projected state.
* Returns null if the item is not found.
*/
public TrackerItem getTrackerItemById(String itemId) {
TrackerSnapshot snapshot = projectTracker();
for (TrackerItem item : snapshot.items()) {
if (item.id().equals(itemId)) {
return item;
}
}
return null;
}

/**
* Project tracker state from events in the "tracker" stream.
* Folds item_created, item_updated, item_archived events into current state.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,4 +151,11 @@ public void shutdown() {
public int getConnectedClientCount() {
return emitters.size();
}

/**
* Get a specific tracker item by ID (for transition validation).
*/
public com.aesop.server.dto.TrackerItem getTrackerItemById(String itemId) {
return eventStoreReader.getTrackerItemById(itemId);
}
}
Loading
Loading