dda4acfbb6
The two "refactor later" items from REVIEW-2026-07-30.md; they share the wire types, so they land together. A capability had four divergent wire shapes — the HTTP handler, the MCP adapter, pkg/client, and Maven's vendored copy of it. There is now a single definition in pkg/client, mapped from domain by internal/wire and used by the HTTP list/create/get paths and all four MCP surfaces. It lives in pkg/client rather than internal so external consumers need not vendor internal/domain, and so producer and consumer are literally the same type. The unified shape is a strict superset of all four predecessors; nothing was dropped. It adds enabled and requires_confirmation to the list responses (never omitempty — an absent bool reads as unknown, not false), capability_id to the MCP and client shapes, and the timing/attribute/version fields previously only on get-by-ID. target_types and the list itself now serialize as [] rather than null. Both `id` and `capability_id` are deliberately kept, carrying the same value. Maven decodes `id`; the spec and the rest of the API say `capability_id`. Bearer auth is already a breaking change for that consumer, and stacking a second silent one is the wrong trade — the redundancy stays until every consumer is confirmed on capability_id, then `id` goes in an announced removal. A test pins this and says so. GET /api/v1/executions?entity_id=&since=&limit= implements spec §4.5, which the Command Center needs. `since` reuses the changes-feed cursor convention rather than inventing a second paging idiom. That cursor is the row's implicit SQLite rowid, which is safe only while nothing deletes executions and nothing VACUUMs — both would renumber and silently invalidate outstanding cursors. If retention is ever added, this must become an explicit monotonic column first; the constraint is documented at the query site. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uea55zaiWuEByEDC4UBSdd
442 lines
14 KiB
Go
442 lines
14 KiB
Go
package api
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kami/hexis/internal/domain"
|
|
"github.com/kami/hexis/internal/execution"
|
|
"github.com/kami/hexis/internal/storage"
|
|
"github.com/kami/hexis/internal/wire"
|
|
)
|
|
|
|
type Handler struct {
|
|
store storage.Interface
|
|
engine *execution.Engine
|
|
authToken string
|
|
}
|
|
|
|
// NewHandler builds the HTTP handler. authToken is the shared bearer token
|
|
// required on every /api/v1/ request; if it is empty the API refuses all
|
|
// /api/v1/ traffic rather than serving it unauthenticated.
|
|
func NewHandler(store storage.Interface, engine *execution.Engine, authToken string) *Handler {
|
|
return &Handler{store: store, engine: engine, authToken: authToken}
|
|
}
|
|
|
|
// SupportedAPIVersion is the version this server implements. A request
|
|
// carrying X-Hexis-Version set to anything else is rejected — clients that
|
|
// don't send the header at all are allowed through unversioned, to avoid
|
|
// breaking callers mid-rollout.
|
|
const SupportedAPIVersion = "v1"
|
|
|
|
func (h *Handler) Register(mux *http.ServeMux) {
|
|
mux.HandleFunc("/health", h.health)
|
|
mux.HandleFunc("/ready", h.ready)
|
|
|
|
api := http.NewServeMux()
|
|
api.HandleFunc("/api/v1/capabilities", h.handleCapabilities)
|
|
api.HandleFunc("/api/v1/capabilities/", h.handleCapabilityByID)
|
|
api.HandleFunc("/api/v1/execute", h.handleExecute)
|
|
api.HandleFunc("/api/v1/confirmations", h.handleConfirmations)
|
|
api.HandleFunc("/api/v1/executions", h.handleExecutions)
|
|
api.HandleFunc("/api/v1/executions/", h.handleExecutionByID)
|
|
api.HandleFunc("/api/v1/changes", h.handleChanges)
|
|
|
|
mux.Handle("/api/v1/", h.requireAuth(versionCheck(api)))
|
|
}
|
|
|
|
// requireAuth enforces the shared bearer token on the whole /api/v1/ surface.
|
|
// /health and /ready stay open so probes keep working.
|
|
func (h *Handler) requireAuth(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if h.authToken == "" {
|
|
// Fail closed: an unset token must never mean "no auth required".
|
|
writeJSON(w, http.StatusServiceUnavailable, errorResponse("server misconfigured: HEXIS_API_TOKEN is not set"))
|
|
return
|
|
}
|
|
presented, ok := bearerToken(r)
|
|
if !ok || subtle.ConstantTimeCompare([]byte(presented), []byte(h.authToken)) != 1 {
|
|
w.Header().Set("WWW-Authenticate", `Bearer realm="hexis"`)
|
|
writeJSON(w, http.StatusUnauthorized, errorResponse("unauthorized"))
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func bearerToken(r *http.Request) (string, bool) {
|
|
h := r.Header.Get("Authorization")
|
|
if h == "" {
|
|
return "", false
|
|
}
|
|
const prefix = "bearer "
|
|
if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
|
|
return "", false
|
|
}
|
|
return strings.TrimSpace(h[len(prefix):]), true
|
|
}
|
|
|
|
func versionCheck(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if v := r.Header.Get("X-Hexis-Version"); v != "" && v != SupportedAPIVersion {
|
|
writeJSON(w, http.StatusPreconditionFailed, map[string]string{
|
|
"error": "unsupported API version",
|
|
"requested_version": v,
|
|
"supported_version": SupportedAPIVersion,
|
|
})
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (h *Handler) health(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (h *Handler) ready(w http.ResponseWriter, r *http.Request) {
|
|
_, err := h.store.LatestSequence()
|
|
if err != nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "not_ready"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
|
}
|
|
|
|
func (h *Handler) handleCapabilities(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
h.listCapabilities(w, r)
|
|
case http.MethodPost:
|
|
h.createCapability(w, r)
|
|
default:
|
|
writeJSON(w, http.StatusMethodNotAllowed, errorResponse("method not allowed"))
|
|
}
|
|
}
|
|
|
|
func (h *Handler) listCapabilities(w http.ResponseWriter, r *http.Request) {
|
|
entityID := r.URL.Query().Get("entity_id")
|
|
caps, err := h.store.ListCapabilities(entityID)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, errorResponse(err.Error()))
|
|
return
|
|
}
|
|
// wire.Capabilities is the single serializer shared with the MCP adapter
|
|
// and pkg/client; it also carries the server-derived `enabled` and
|
|
// `requires_confirmation` fields this endpoint previously omitted.
|
|
writeJSON(w, http.StatusOK, wire.Capabilities(caps))
|
|
}
|
|
|
|
func (h *Handler) createCapability(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
TargetTypes []string `json:"target_types"`
|
|
TargetEntityID string `json:"target_entity_id,omitempty"`
|
|
Provider string `json:"provider"`
|
|
Operation string `json:"operation"`
|
|
Risk string `json:"risk,omitempty"`
|
|
ReadOnly bool `json:"read_only"`
|
|
ExpectedSideEffects string `json:"expected_side_effects,omitempty"`
|
|
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
|
|
Attributes map[string]any `json:"attributes,omitempty"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse("invalid JSON"))
|
|
return
|
|
}
|
|
if req.Name == "" || req.Provider == "" || req.Operation == "" {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse("name, provider, and operation are required"))
|
|
return
|
|
}
|
|
|
|
// `enabled` and `requires_confirmation` are derived server-side from the
|
|
// risk tier and are deliberately NOT settable from the request body — a
|
|
// caller-supplied override would make the ECOSYSTEM-SPEC.md §4.3 guards
|
|
// opt-out for the untrusted caller they exist to constrain.
|
|
enabled := domain.EnabledForRisk(req.Risk)
|
|
requiresConfirmation := domain.RequiresConfirmationForRisk(req.Risk)
|
|
|
|
timeoutSeconds := req.TimeoutSeconds
|
|
if timeoutSeconds <= 0 {
|
|
timeoutSeconds = domain.DefaultCapabilityTimeoutSeconds
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
cap := &domain.Capability{
|
|
ID: domain.NewCapabilityID(),
|
|
Name: req.Name,
|
|
Description: req.Description,
|
|
TargetTypes: req.TargetTypes,
|
|
TargetEntityID: req.TargetEntityID,
|
|
Provider: req.Provider,
|
|
Operation: req.Operation,
|
|
Risk: req.Risk,
|
|
ReadOnly: req.ReadOnly,
|
|
ExpectedSideEffects: req.ExpectedSideEffects,
|
|
RequiresConfirmation: requiresConfirmation,
|
|
Enabled: enabled,
|
|
TimeoutSeconds: timeoutSeconds,
|
|
Attributes: req.Attributes,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
Version: 1,
|
|
}
|
|
if cap.TargetTypes == nil {
|
|
cap.TargetTypes = []string{}
|
|
}
|
|
if cap.Attributes == nil {
|
|
cap.Attributes = map[string]any{}
|
|
}
|
|
|
|
if err := h.store.CreateCapability(cap); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, errorResponse(err.Error()))
|
|
return
|
|
}
|
|
|
|
h.store.AppendEvent(&domain.Event{
|
|
ID: domain.NewEventID(),
|
|
Type: domain.EventCapabilityRegistered,
|
|
Timestamp: now,
|
|
Payload: map[string]any{"capability_id": cap.ID, "name": cap.Name},
|
|
})
|
|
|
|
writeJSON(w, http.StatusCreated, wire.Capability(cap))
|
|
}
|
|
|
|
func (h *Handler) handleCapabilityByID(w http.ResponseWriter, r *http.Request) {
|
|
id := strings.TrimPrefix(r.URL.Path, "/api/v1/capabilities/")
|
|
if id == "" {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse("id required"))
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
h.getCapability(w, r, id)
|
|
case http.MethodDelete:
|
|
h.deleteCapability(w, r, id)
|
|
default:
|
|
writeJSON(w, http.StatusMethodNotAllowed, errorResponse("method not allowed"))
|
|
}
|
|
}
|
|
|
|
func (h *Handler) getCapability(w http.ResponseWriter, r *http.Request, id string) {
|
|
cap, err := h.store.GetCapability(id)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusNotFound, errorResponse(err.Error()))
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, wire.Capability(cap))
|
|
}
|
|
|
|
func (h *Handler) deleteCapability(w http.ResponseWriter, r *http.Request, id string) {
|
|
if err := h.store.DeleteCapability(id); err != nil {
|
|
writeJSON(w, http.StatusNotFound, errorResponse(err.Error()))
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
|
}
|
|
|
|
func (h *Handler) handleExecute(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeJSON(w, http.StatusMethodNotAllowed, errorResponse("method not allowed"))
|
|
return
|
|
}
|
|
|
|
var req domain.ExecuteRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse("invalid JSON"))
|
|
return
|
|
}
|
|
if req.CapabilityID == "" || req.TargetEntityID == "" {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse("capability_id and target_entity_id are required"))
|
|
return
|
|
}
|
|
if req.CorrelationID == "" {
|
|
req.CorrelationID = r.Header.Get("X-Correlation-ID")
|
|
}
|
|
if req.CausationID == "" {
|
|
req.CausationID = r.Header.Get("X-Causation-ID")
|
|
}
|
|
|
|
result, err := h.engine.Execute(&req)
|
|
if err != nil {
|
|
writeJSON(w, executeErrorStatus(err), errorResponse(err.Error()))
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, result.Execution)
|
|
}
|
|
|
|
func executeErrorStatus(err error) int {
|
|
switch {
|
|
case errors.Is(err, domain.ErrExecutionInFlight):
|
|
return http.StatusConflict
|
|
case errors.Is(err, domain.ErrCapabilityNotFound),
|
|
errors.Is(err, execution.ErrTargetNotFound):
|
|
return http.StatusNotFound
|
|
case errors.Is(err, execution.ErrTargetMalformed):
|
|
return http.StatusBadRequest
|
|
case errors.Is(err, execution.ErrTargetTypeMismatch),
|
|
errors.Is(err, execution.ErrTargetNotActive):
|
|
return http.StatusUnprocessableEntity
|
|
// Nexus is the only authority on whether a target is real. If it cannot
|
|
// be reached we refuse the execution rather than accepting the target on
|
|
// trust — 503, because retrying later is the correct client behaviour.
|
|
case errors.Is(err, execution.ErrTargetUnverifiable):
|
|
return http.StatusServiceUnavailable
|
|
case errors.Is(err, domain.ErrConfirmationRequired),
|
|
errors.Is(err, domain.ErrConfirmationInvalid),
|
|
errors.Is(err, domain.ErrConfirmationExpired),
|
|
errors.Is(err, domain.ErrConfirmationConsumed),
|
|
errors.Is(err, domain.ErrConfirmationNotFound),
|
|
errors.Is(err, domain.ErrCapabilityDisabled),
|
|
errors.Is(err, domain.ErrCapabilityNotBound):
|
|
return http.StatusForbidden
|
|
default:
|
|
return http.StatusBadRequest
|
|
}
|
|
}
|
|
|
|
func (h *Handler) handleConfirmations(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeJSON(w, http.StatusMethodNotAllowed, errorResponse("method not allowed"))
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
CapabilityID string `json:"capability_id"`
|
|
TargetEntityID string `json:"target_entity_id"`
|
|
Arguments map[string]any `json:"arguments,omitempty"`
|
|
Requester string `json:"requester,omitempty"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse("invalid JSON"))
|
|
return
|
|
}
|
|
if req.CapabilityID == "" || req.TargetEntityID == "" {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse("capability_id and target_entity_id are required"))
|
|
return
|
|
}
|
|
|
|
conf, err := h.engine.CreateConfirmation(req.CapabilityID, req.TargetEntityID, req.Requester, req.Arguments)
|
|
if err != nil {
|
|
writeJSON(w, executeErrorStatus(err), errorResponse(err.Error()))
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, conf)
|
|
}
|
|
|
|
// handleExecutions serves GET /api/v1/executions?entity_id=&since=&limit=
|
|
// (ECOSYSTEM-SPEC.md §4.5) — the execution history the Command Center's
|
|
// Overview and Executions surfaces render.
|
|
//
|
|
// `since` follows the same cursor convention as /api/v1/changes: an integer
|
|
// sequence, exclusive, with results ordered ascending. Callers page by passing
|
|
// the `seq` of the last execution they saw. This deliberately reuses the
|
|
// changes-feed style rather than introducing a timestamp cursor, so a client
|
|
// only has to learn one paging idiom against Hexis.
|
|
func (h *Handler) handleExecutions(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeJSON(w, http.StatusMethodNotAllowed, errorResponse("method not allowed"))
|
|
return
|
|
}
|
|
|
|
q := r.URL.Query()
|
|
|
|
var since int64
|
|
if s := q.Get("since"); s != "" {
|
|
parsed, err := strconv.ParseInt(s, 10, 64)
|
|
if err != nil || parsed < 0 {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse("since must be a non-negative integer sequence"))
|
|
return
|
|
}
|
|
since = parsed
|
|
}
|
|
|
|
limit := storage.MaxExecutionPageSize
|
|
if l := q.Get("limit"); l != "" {
|
|
parsed, err := strconv.Atoi(l)
|
|
if err != nil || parsed <= 0 {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse("limit must be a positive integer"))
|
|
return
|
|
}
|
|
limit = parsed
|
|
}
|
|
|
|
execs, err := h.store.ListExecutions(q.Get("entity_id"), since, limit)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, errorResponse(err.Error()))
|
|
return
|
|
}
|
|
if execs == nil {
|
|
execs = []*domain.Execution{}
|
|
}
|
|
writeJSON(w, http.StatusOK, execs)
|
|
}
|
|
|
|
func (h *Handler) handleExecutionByID(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeJSON(w, http.StatusMethodNotAllowed, errorResponse("method not allowed"))
|
|
return
|
|
}
|
|
|
|
id := strings.TrimPrefix(r.URL.Path, "/api/v1/executions/")
|
|
if id == "" {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse("id required"))
|
|
return
|
|
}
|
|
|
|
exec, err := h.store.GetExecution(id)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusNotFound, errorResponse(err.Error()))
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, exec)
|
|
}
|
|
|
|
func (h *Handler) handleChanges(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeJSON(w, http.StatusMethodNotAllowed, errorResponse("method not allowed"))
|
|
return
|
|
}
|
|
|
|
seqStr := r.URL.Query().Get("since")
|
|
var since int64
|
|
if seqStr != "" {
|
|
parsed, err := strconv.ParseInt(seqStr, 10, 64)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse("since must be an integer sequence"))
|
|
return
|
|
}
|
|
since = parsed
|
|
}
|
|
|
|
events, err := h.store.EventsAfter(since, 100)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, errorResponse(err.Error()))
|
|
return
|
|
}
|
|
if events == nil {
|
|
events = []*domain.Event{}
|
|
}
|
|
writeJSON(w, http.StatusOK, events)
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func errorResponse(msg string) map[string]string {
|
|
return map[string]string{"error": msg}
|
|
}
|