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, 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, 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): return http.StatusNotFound 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 { status := http.StatusBadRequest if errors.Is(err, domain.ErrCapabilityNotFound) { status = http.StatusNotFound } else if errors.Is(err, domain.ErrCapabilityNotBound) { status = http.StatusForbidden } writeJSON(w, status, errorResponse(err.Error())) return } writeJSON(w, http.StatusCreated, conf) } 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} }