// Package api serves the FlowSight REST API (docs/API.md) on chi: flow // summary/broker/foreign, screener, routines, briefing, alerts, reports, // watchlist, portfolio risk, accuracy, chat (report-scoped), health, and the // SSE stream (agents/alerts/activity, 15s heartbeat). Demo auth: X-User-Key. package api import ( "encoding/json" "net/http" "strings" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-playground/validator/v10" "flowsight/internal/agents" "flowsight/internal/config" "flowsight/internal/llm" "flowsight/internal/reports" "flowsight/internal/routines" "flowsight/internal/scheduler" "flowsight/internal/sectors" "flowsight/internal/store" ) // Server wires all handlers. type Server struct { Cfg config.Config DB *store.DB Sectors *sectors.Client Sched *scheduler.Scheduler Engine *routines.Engine Builder *reports.Builder LLM *llm.Client Validate *validator.Validate Hub *Hub // StartedAt powers /api/version (CI anti-stale proof: process age). StartedAt time.Time } // New builds a Server with all dependencies wired. func New(cfg config.Config, db *store.DB, cache *store.Cache, s *sectors.Client) *Server { llmc := llm.New(cfg.LLMBaseURL, cfg.LLMAPIKey) sched := scheduler.New(cfg, db, cache, s) srv := &Server{ Cfg: cfg, DB: db, Sectors: s, Sched: sched, LLM: llmc, Validate: validator.New(), Hub: NewHub(), StartedAt: time.Now(), } srv.Engine = &routines.Engine{DB: db, Notifier: sched.Notifier, UserKey: cfg.DemoUserKey, Publish: srv.Hub.Publish} sched.Publish = srv.Hub.Publish srv.Builder = &reports.Builder{DB: db, Deps: agents.Deps{ DB: db, LLM: llmc, TriageModel: cfg.LLMTriage, SynthModel: cfg.LLMSynth, Now: time.Now(), }} return srv } // Router returns the chi mux with all routes. func (s *Server) Router() http.Handler { r := chi.NewRouter() r.Use(middleware.Logger, middleware.Recoverer, middleware.Heartbeat("/ping")) // Cap request bodies (1 MiB) so large POSTs cannot exhaust memory. // JSON bodies here are tiny (auth, screen filters, chat prompts). r.Use(func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { if req.Body != nil && (req.Method == http.MethodPost || req.Method == http.MethodPut || req.Method == http.MethodPatch) { req.Body = http.MaxBytesReader(w, req.Body, 1<<20) } next.ServeHTTP(w, req) }) }) r.Route("/api", func(r chi.Router) { r.Get("/health", s.Health) r.Get("/auth/start", s.AuthStart) r.Get("/auth/callback", s.AuthCallback) r.Get("/auth/me", s.AuthMe) r.Post("/auth/logout", s.AuthLogout) // Rate-limited auth endpoints: max 10 per IP per 60s. authRL := newIPRateLimiter(10, 60*time.Second) r.Group(func(r chi.Router) { r.Use(func(next http.Handler) http.Handler { return s.rateLimitAuth(authRL, next) }) r.Post("/auth/signup", s.AuthSignup) r.Post("/auth/login", s.AuthLogin) }) r.Get("/version", s.Version) // Publik baca: dashboard bisa dibuka tanpa login. Fitur + filter di bawah // wajib login (session cookie, tanpa demo bypass). r.Get("/flow/summary", s.FlowSummary) r.Get("/flow/broker", s.FlowBroker) r.Get("/flow/foreign", s.FlowForeign) r.Get("/briefing/today", s.BriefingToday) r.Group(func(r chi.Router) { r.Use(s.requireLogin) r.Get("/stream", s.Stream) r.Post("/screen", s.Screen) r.Get("/routines", s.ListRoutines) r.Post("/routines", s.CreateRoutine) r.Patch("/routines/{id}", s.UpdateRoutine) r.Delete("/routines/{id}", s.DeleteRoutine) r.Get("/routine-runs", s.RunHistory) r.Get("/alerts", s.ListAlerts) r.Post("/alerts", s.CreateAlert) r.Delete("/alerts/{id}", s.DeleteAlert) r.Get("/alert-events", s.AlertEvents) r.Get("/destinations", s.ListDestinations) r.Post("/destinations", s.CreateDestination) r.Patch("/destinations/{id}", s.UpdateDestination) r.Delete("/destinations/{id}", s.DeleteDestination) r.Post("/report/{ticker}", s.BuildReport) r.Post("/report/{ticker}/ask", s.Interrogate) r.Get("/watchlist", s.GetWatchlist) r.Post("/watchlist", s.AddWatch) r.Delete("/watchlist/{ticker}", s.RemoveWatch) r.Get("/portfolio/risk", s.PortfolioRisk) r.Get("/accuracy", s.Accuracy) r.Post("/chat", s.Chat) }) }) if s.Cfg.StaticDir != "" { r.NotFound(s.spaHandler()) } return r } // userKey resolves the request owner: session cookie first (Google login -> // `u:`), then the demo header, then the shared demo key. func (s *Server) userKey(r *http.Request) string { if u, ok := s.sessionUser(r); ok { return u.UserKey } if k := strings.TrimSpace(r.Header.Get("X-User-Key")); k != "" { return k } return s.Cfg.DemoUserKey } func writeJSON(w http.ResponseWriter, code int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(v) } func writeErr(w http.ResponseWriter, code int, msg string) { writeJSON(w, code, map[string]any{"error": map[string]string{"code": http.StatusText(code), "message": msg}}) }