package api import ( "net/http" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/breakpilot/pca-platform/heuristic-service/internal/config" "github.com/breakpilot/pca-platform/heuristic-service/internal/heuristics" "github.com/breakpilot/pca-platform/heuristic-service/internal/stepup" ) // Handler holds all API handlers type Handler struct { config *config.Config scorer *heuristics.Scorer webauthn *stepup.WebAuthnService pow *stepup.PoWService } // NewHandler creates a new API handler func NewHandler(cfg *config.Config) *Handler { return &Handler{ config: cfg, scorer: heuristics.NewScorer(cfg), webauthn: stepup.NewWebAuthnService(&cfg.StepUp.WebAuthn), pow: stepup.NewPoWService(&cfg.StepUp.PoW), } } // TickRequest represents metrics sent from client SDK type TickRequest struct { SessionID string `json:"session_id"` Score float64 `json:"score,omitempty"` DwellRatio float64 `json:"dwell_ratio"` ScrollDepth float64 `json:"scroll_depth"` Clicks int `json:"clicks"` MouseMoves int `json:"mouse_moves"` KeyStrokes int `json:"key_strokes,omitempty"` TouchEvents int `json:"touch_events,omitempty"` MouseVelocities []float64 `json:"mouse_velocities,omitempty"` ScrollVelocities []float64 `json:"scroll_velocities,omitempty"` ClickIntervals []float64 `json:"click_intervals,omitempty"` Timestamp int64 `json:"ts"` } // TickResponse returns the computed score and action type TickResponse struct { SessionID string `json:"session_id"` Score float64 `json:"score"` Action string `json:"action"` StepUpMethod string `json:"step_up_method,omitempty"` Message string `json:"message,omitempty"` } // HandleTick receives tick data from client SDK func (h *Handler) HandleTick(c *gin.Context) { var req TickRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) return } // Generate session ID if not provided if req.SessionID == "" { req.SessionID = uuid.New().String() } // Get or create session session := h.scorer.GetOrCreateSession(req.SessionID) // Update metrics totalTime := time.Since(session.StartTime).Seconds() session.VisibleTime = req.DwellRatio * totalTime session.MaxScrollPercent = req.ScrollDepth / 100.0 // Convert from percentage session.ClickCount = req.Clicks session.MouseMoves = req.MouseMoves session.KeyStrokes = req.KeyStrokes session.TouchEvents = req.TouchEvents if len(req.MouseVelocities) > 0 { session.MouseVelocities = append(session.MouseVelocities, req.MouseVelocities...) } if len(req.ScrollVelocities) > 0 { session.ScrollVelocities = append(session.ScrollVelocities, req.ScrollVelocities...) } if len(req.ClickIntervals) > 0 { session.ClickIntervals = append(session.ClickIntervals, req.ClickIntervals...) } // Calculate score score := h.scorer.CalculateScore(req.SessionID) // Determine action var action, stepUpMethod, message string if score >= h.config.Thresholds.ScorePass { action = "allow" message = "Human behavior detected" } else if score >= h.config.Thresholds.ScoreChallenge { action = "allow" message = "Acceptable behavior" } else { action = "challenge" stepUpMethod = h.config.StepUp.Primary message = "Additional verification required" } c.JSON(http.StatusOK, TickResponse{ SessionID: req.SessionID, Score: score, Action: action, StepUpMethod: stepUpMethod, Message: message, }) } // HandleEvaluate evaluates a session for a specific path func (h *Handler) HandleEvaluate(c *gin.Context) { sessionID := c.Query("session_id") path := c.Query("path") if sessionID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "session_id required"}) return } if path == "" { path = "default" } // TODO: Load path configs from ai-access.json pathConfigs := map[string]config.PathConfig{} result := h.scorer.EvaluateRequest(sessionID, path, pathConfigs) c.JSON(http.StatusOK, result) } // HandleWebAuthnChallenge creates a WebAuthn challenge func (h *Handler) HandleWebAuthnChallenge(c *gin.Context) { if !h.webauthn.IsEnabled() { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "WebAuthn not enabled"}) return } sessionID := c.Query("session_id") if sessionID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "session_id required"}) return } challenge, err := h.webauthn.CreateChallenge(sessionID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create challenge"}) return } c.JSON(http.StatusOK, challenge) } // HandleWebAuthnVerify verifies a WebAuthn assertion func (h *Handler) HandleWebAuthnVerify(c *gin.Context) { if !h.webauthn.IsEnabled() { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "WebAuthn not enabled"}) return } var req stepup.VerifyRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) return } verified, err := h.webauthn.VerifyChallenge(&req) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Verification failed"}) return } c.JSON(http.StatusOK, gin.H{ "verified": verified, "session_id": req.SessionID, }) } // HandlePoWChallenge creates a Proof-of-Work challenge func (h *Handler) HandlePoWChallenge(c *gin.Context) { if !h.pow.IsEnabled() { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "PoW not enabled"}) return } sessionID := c.Query("session_id") if sessionID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "session_id required"}) return } challenge, err := h.pow.CreateChallenge(sessionID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create challenge"}) return } c.JSON(http.StatusOK, challenge) } // HandlePoWVerify verifies a Proof-of-Work solution func (h *Handler) HandlePoWVerify(c *gin.Context) { if !h.pow.IsEnabled() { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "PoW not enabled"}) return } var req stepup.PoWVerifyRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) return } verified, err := h.pow.VerifyChallenge(&req) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Verification failed"}) return } c.JSON(http.StatusOK, gin.H{ "verified": verified, "session_id": req.SessionID, }) } // HandleGetConfig returns client-safe configuration func (h *Handler) HandleGetConfig(c *gin.Context) { // Return only non-sensitive config for client SDK clientConfig := gin.H{ "thresholds": h.config.Thresholds, "weights": h.config.Weights, "tick": gin.H{ "endpoint": h.config.Tick.Endpoint, "interval_ms": h.config.Tick.IntervalMs, }, "step_up": gin.H{ "methods": h.config.StepUp.Methods, "primary": h.config.StepUp.Primary, "webauthn": gin.H{ "enabled": h.config.StepUp.WebAuthn.Enabled, "userVerification": h.config.StepUp.WebAuthn.UserVerification, "timeout_ms": h.config.StepUp.WebAuthn.TimeoutMs, "challenge_endpoint": h.config.StepUp.WebAuthn.ChallengeEndpoint, }, "pow": gin.H{ "enabled": h.config.StepUp.PoW.Enabled, "difficulty": h.config.StepUp.PoW.Difficulty, "max_duration_ms": h.config.StepUp.PoW.MaxDurationMs, }, }, "compliance": h.config.Compliance, } c.JSON(http.StatusOK, clientConfig) } // HandleHealth returns service health func (h *Handler) HandleHealth(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "status": "healthy", "service": "pca-heuristic-service", "version": "0.1.0", }) } // StartCleanupRoutine starts background cleanup func (h *Handler) StartCleanupRoutine() { go func() { ticker := time.NewTicker(5 * time.Minute) for range ticker.C { h.scorer.CleanupOldSessions(30 * time.Minute) h.webauthn.CleanupExpiredChallenges() h.pow.CleanupExpiredChallenges() } }() }