diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 62de0f0..4c6d1dc 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -154,7 +154,7 @@ jobs:
sleep 3 && \
sudo systemctl status flowsight --no-pager --no-legend | head -5"
- - name: Verify service
+ - name: Verify service anti-stale
run: |
ssh "$VPS_USER@$VPS_HOST" \
"curl -s -o /dev/null -w 'local:%{http_code}\n' http://localhost:4022/api/health && \
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index 7cd9a8b..93a027d 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -61,9 +61,14 @@ func main() {
}
}
}
- // Ensure the demo watchlist exists even without a seed bundle.
+ // Ensure the demo account covers the "semua" default: every ticker with
+ // stored data (fallback: config watchlist).
if wl, _ := db.Watchlist(cfg.DemoUserKey); len(wl) == 0 {
- for _, t := range cfg.Watchlist {
+ seeds, _ := db.AllTickers()
+ if len(seeds) == 0 {
+ seeds = cfg.Watchlist
+ }
+ for _, t := range seeds {
_ = db.AddWatch(cfg.DemoUserKey, t)
}
}
diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go
index 303d88f..61548d3 100644
--- a/backend/internal/api/api_test.go
+++ b/backend/internal/api/api_test.go
@@ -65,7 +65,8 @@ func TestBriefing(t *testing.T) {
rec := do(s, "GET", "/api/briefing/today", nil)
if rec.Code == http.StatusNotFound {
// No briefing yet: run the routine via engine path instead.
- rows, _ := s.DB.ListRoutines("demo")
+ u, _ := s.DB.CheckLocalUser("tester", "password1234")
+ rows, _ := s.DB.ListRoutines(u.UserKey)
if len(rows) == 0 {
t.Fatal("seed has no routines")
}
@@ -82,7 +83,7 @@ func TestBriefing(t *testing.T) {
// Screener returns a ranked list with per-row breakdown.
func TestScreen(t *testing.T) {
s := testServer(t)
- rec := do(s, "POST", "/api/screen", map[string]any{"limit": 5})
+ rec := doAuth(t, s, "POST", "/api/screen", map[string]any{"limit": 5})
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
@@ -101,7 +102,7 @@ func TestScreen(t *testing.T) {
// Report: all 7 sections populated with citations.
func TestReport(t *testing.T) {
s := testServer(t)
- rec := do(s, "POST", "/api/report/BBCA?profile=moderate", nil)
+ rec := doAuth(t, s, "POST", "/api/report/BBCA?profile=moderate", nil)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d, body %s", rec.Code, rec.Body.String()[:300])
}
@@ -122,20 +123,21 @@ func TestReport(t *testing.T) {
// Subscribe -> run -> history row appears.
func TestRoutineSubscribeRunHistory(t *testing.T) {
s := testServer(t)
- rec := do(s, "POST", "/api/routines", map[string]any{"type": "foreign-reversal"})
+ rec := doAuth(t, s, "POST", "/api/routines", map[string]any{"type": "foreign-reversal"})
if rec.Code != http.StatusCreated {
t.Fatalf("code = %d", rec.Code)
}
var created map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &created)
- rows, _ := s.DB.ListRoutines("demo")
+ u, _ := s.DB.CheckLocalUser("tester", "password1234")
+ rows, _ := s.DB.ListRoutines(u.UserKey)
if len(rows) == 0 {
t.Fatal("no routines")
}
if _, err := s.Engine.Run(httptest.NewRequest("GET", "/", nil).Context(), rows[0]); err != nil {
t.Fatal(err)
}
- rec = do(s, "GET", "/api/routine-runs?limit=5", nil)
+ rec = doAuth(t, s, "GET", "/api/routine-runs?limit=5", nil)
var out struct {
Runs []map[string]any `json:"runs"`
}
@@ -149,11 +151,11 @@ func TestRoutineSubscribeRunHistory(t *testing.T) {
// from the persisted report, unknown report 404s.
func TestInterrogate(t *testing.T) {
s := testServer(t)
- rec := do(s, "POST", "/api/report/BBCA?profile=moderate", nil)
+ rec := doAuth(t, s, "POST", "/api/report/BBCA?profile=moderate", nil)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
- rec = do(s, "POST", "/api/report/BBCA/ask", map[string]any{"question": "kenapa conviction segitu?"})
+ rec = doAuth(t, s, "POST", "/api/report/BBCA/ask", map[string]any{"question": "kenapa conviction segitu?"})
if rec.Code != http.StatusOK {
t.Fatalf("code = %d, body %s", rec.Code, rec.Body.String()[:200])
}
@@ -164,7 +166,7 @@ func TestInterrogate(t *testing.T) {
if out.Answer == "" {
t.Fatal("empty interrogation answer")
}
- rec = do(s, "POST", "/api/report/ZZZZ/ask", map[string]any{"question": "apa?"})
+ rec = doAuth(t, s, "POST", "/api/report/ZZZZ/ask", map[string]any{"question": "apa?"})
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404 for unknown ticker", rec.Code)
}
@@ -173,11 +175,11 @@ func TestInterrogate(t *testing.T) {
// Concentrated fixture warns >40% sector; accuracy math covered.
func TestPortfolioAndAccuracy(t *testing.T) {
s := testServer(t)
- rec := do(s, "GET", "/api/portfolio/risk", nil)
+ rec := doAuth(t, s, "GET", "/api/portfolio/risk", nil)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
- rec = do(s, "GET", "/api/accuracy", nil)
+ rec = doAuth(t, s, "GET", "/api/accuracy", nil)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
@@ -203,19 +205,19 @@ func TestFlowForeignWindow(t *testing.T) {
// Unknown routine types are rejected; missing ids 404.
func TestRoutineValidation(t *testing.T) {
s := testServer(t)
- rec := do(s, "POST", "/api/routines", map[string]any{"type": "not-a-routine"})
+ rec := doAuth(t, s, "POST", "/api/routines", map[string]any{"type": "not-a-routine"})
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("code = %d, want 422", rec.Code)
}
- rec = do(s, "PATCH", "/api/routines/999999", map[string]any{"enabled": false})
+ rec = doAuth(t, s, "PATCH", "/api/routines/999999", map[string]any{"enabled": false})
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", rec.Code)
}
- rec = do(s, "DELETE", "/api/routines/999999", nil)
+ rec = doAuth(t, s, "DELETE", "/api/routines/999999", nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", rec.Code)
}
- rec = do(s, "DELETE", "/api/alerts/999999", nil)
+ rec = doAuth(t, s, "DELETE", "/api/alerts/999999", nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", rec.Code)
}
@@ -225,27 +227,27 @@ func TestRoutineValidation(t *testing.T) {
func TestDestinations(t *testing.T) {
s := testServer(t)
// Invalid kind -> 422.
- rec := do(s, "POST", "/api/destinations", map[string]any{"kind": "sms"})
+ rec := doAuth(t, s, "POST", "/api/destinations", map[string]any{"kind": "sms"})
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("code = %d, want 422", rec.Code)
}
// Telegram without chat_id -> 422.
- rec = do(s, "POST", "/api/destinations", map[string]any{"kind": "telegram", "bot_token": "x"})
+ rec = doAuth(t, s, "POST", "/api/destinations", map[string]any{"kind": "telegram", "bot_token": "x"})
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("code = %d, want 422", rec.Code)
}
// Discord non-https -> 422.
- rec = do(s, "POST", "/api/destinations", map[string]any{"kind": "discord", "webhook_url": "http://x"})
+ rec = doAuth(t, s, "POST", "/api/destinations", map[string]any{"kind": "discord", "webhook_url": "http://x"})
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("code = %d, want 422", rec.Code)
}
// Valid discord create -> 201.
- rec = do(s, "POST", "/api/destinations", map[string]any{"kind": "discord", "label": "ops", "webhook_url": "https://discord.example/hook"})
+ rec = doAuth(t, s, "POST", "/api/destinations", map[string]any{"kind": "discord", "label": "ops", "webhook_url": "https://discord.example/hook"})
if rec.Code != http.StatusCreated {
t.Fatalf("code = %d, body %s", rec.Code, rec.Body.String())
}
// List masks secrets.
- rec = do(s, "GET", "/api/destinations", nil)
+ rec = doAuth(t, s, "GET", "/api/destinations", nil)
var out struct {
Destinations []map[string]any `json:"destinations"`
}
@@ -262,11 +264,11 @@ func TestDestinations(t *testing.T) {
t.Fatalf("configured flag = %v", out.Destinations[0])
}
// Missing id -> 404 on patch and delete.
- rec = do(s, "PATCH", "/api/destinations/999999", map[string]any{"enabled": false})
+ rec = doAuth(t, s, "PATCH", "/api/destinations/999999", map[string]any{"enabled": false})
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", rec.Code)
}
- rec = do(s, "DELETE", "/api/destinations/999999", nil)
+ rec = doAuth(t, s, "DELETE", "/api/destinations/999999", nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", rec.Code)
}
diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go
index d4e8f57..4c58413 100644
--- a/backend/internal/api/auth.go
+++ b/backend/internal/api/auth.go
@@ -77,9 +77,13 @@ func (s *Server) AuthCallback(w http.ResponseWriter, r *http.Request) {
fail("user store unavailable")
return
}
- // Ensure the login user owns the default watchlist on first login.
+ // Ensure the login user owns the "semua" default watchlist on first login.
if wl, _ := s.DB.Watchlist(user.UserKey); len(wl) == 0 {
- for _, t := range s.Cfg.Watchlist {
+ seeds, _ := s.DB.AllTickers()
+ if len(seeds) == 0 {
+ seeds = s.Cfg.Watchlist
+ }
+ for _, t := range seeds {
_ = s.DB.AddWatch(user.UserKey, t)
}
}
@@ -133,6 +137,120 @@ func (s *Server) sessionUser(r *http.Request) (*store.User, bool) {
return s.DB.SessionUser(c.Value)
}
+// requireLogin is chi middleware: 401 unless a valid session cookie is
+// present. No X-User-Key/demo fallback β fitur dan filter milik user yg
+// login. Dashboard (flow/*, briefing) tetap publik di luar grup ini.
+func (s *Server) requireLogin(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if _, ok := s.sessionUser(r); !ok {
+ writeErr(w, http.StatusUnauthorized, "login dulu untuk pakai fitur ini")
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+// localCreds decodes {username, password} with shared validation.
+func localCreds(r *http.Request) (string, string, bool) {
+ var req struct {
+ Username string `json:"username"`
+ Password string `json:"password"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ return "", "", false
+ }
+ return strings.ToLower(strings.TrimSpace(req.Username)), req.Password, true
+}
+
+// mintSession creates a session + sets the fs_session cookie.
+func (s *Server) mintSession(w http.ResponseWriter, user *store.User) bool {
+ tok, err := s.DB.CreateSession(user.ID, user.UserKey, sessionTTL)
+ if err != nil {
+ writeErr(w, http.StatusBadGateway, "session store unavailable")
+ return false
+ }
+ http.SetCookie(w, &http.Cookie{
+ Name: "fs_session", Value: tok, Path: "/", HttpOnly: true,
+ Secure: true, SameSite: http.SameSiteLaxMode,
+ Expires: time.Now().Add(sessionTTL),
+ })
+ return true
+}
+
+// userJSON renders the /api/auth/me shape for one user.
+func (s *Server) userJSON(u *store.User) map[string]any {
+ return map[string]any{
+ "id": u.ID, "email": u.Email, "name": u.Name,
+ "avatar_url": u.AvatarURL, "user_key": u.UserKey,
+ "google_configured": s.Cfg.HasGoogle(),
+ }
+}
+
+// seedWatchlistSemua gives a fresh user the "semua" default: every ticker
+// with stored data (fallback: config watchlist, last: BBCA).
+func (s *Server) seedWatchlistSemua(userKey string) {
+ if wl, _ := s.DB.Watchlist(userKey); len(wl) > 0 {
+ return
+ }
+ seeds, _ := s.DB.AllTickers()
+ if len(seeds) == 0 {
+ seeds = s.Cfg.Watchlist
+ }
+ if len(seeds) == 0 {
+ seeds = []string{"BBCA"}
+ }
+ for _, t := range seeds {
+ _ = s.DB.AddWatch(userKey, t)
+ }
+}
+
+// AuthSignup serves POST /api/auth/signup {username, password}: registers a
+// local account, seeds the "semua" watchlist, logs in immediately.
+func (s *Server) AuthSignup(w http.ResponseWriter, r *http.Request) {
+ username, password, ok := localCreds(r)
+ if !ok {
+ writeErr(w, http.StatusBadRequest, "invalid JSON body")
+ return
+ }
+ user, err := s.DB.CreateLocalUser(username, password)
+ if err != nil {
+ switch err {
+ case store.ErrTaken:
+ writeErr(w, http.StatusConflict, err.Error())
+ case store.ErrBadUsername, store.ErrBadPassword:
+ writeErr(w, http.StatusUnprocessableEntity, err.Error())
+ default:
+ writeErr(w, http.StatusBadGateway, "db: "+err.Error())
+ }
+ return
+ }
+ s.seedWatchlistSemua(user.UserKey)
+ if !s.mintSession(w, user) {
+ return
+ }
+ writeJSON(w, http.StatusCreated, map[string]any{"user": s.userJSON(user)})
+}
+
+// AuthLogin serves POST /api/auth/login {username, password}: verifies the
+// local account and mints a session.
+func (s *Server) AuthLogin(w http.ResponseWriter, r *http.Request) {
+ username, password, ok := localCreds(r)
+ if !ok {
+ writeErr(w, http.StatusBadRequest, "invalid JSON body")
+ return
+ }
+ user, err := s.DB.CheckLocalUser(username, password)
+ if err != nil {
+ writeErr(w, http.StatusUnauthorized, "username atau password salah")
+ return
+ }
+ s.seedWatchlistSemua(user.UserKey)
+ if !s.mintSession(w, user) {
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"user": s.userJSON(user)})
+}
+
// googleTokenResp is the subset of oauth2.googleapis.com/token we need.
type googleTokenResp struct {
IDToken string `json:"id_token"`
diff --git a/backend/internal/api/flow.go b/backend/internal/api/flow.go
index f5bb02c..2537898 100644
--- a/backend/internal/api/flow.go
+++ b/backend/internal/api/flow.go
@@ -19,7 +19,11 @@ func (s *Server) FlowSummary(w http.ResponseWriter, r *http.Request) {
}
wl, _ := s.DB.Watchlist(s.userKey(r))
if len(wl) == 0 {
- wl = s.Cfg.Watchlist
+ if all, err := s.DB.AllTickers(); err == nil && len(all) > 0 {
+ wl = all
+ } else {
+ wl = s.Cfg.Watchlist
+ }
}
type accRow struct {
Ticker string `json:"ticker"`
diff --git a/backend/internal/api/gated_test.go b/backend/internal/api/gated_test.go
new file mode 100644
index 0000000..d807253
--- /dev/null
+++ b/backend/internal/api/gated_test.go
@@ -0,0 +1,109 @@
+package api
+
+import (
+ "bytes"
+ "encoding/json"
+ "time"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+// Gated routes 401 without session; public routes stay 200.
+func TestGatedRequiresLogin(t *testing.T) {
+ s := testServer(t)
+ gated := []struct{ method, path string }{
+ {"POST", "/api/screen"}, {"GET", "/api/routines"}, {"POST", "/api/routines"},
+ {"GET", "/api/routine-runs"}, {"GET", "/api/alerts"}, {"POST", "/api/alerts"},
+ {"GET", "/api/alert-events"}, {"GET", "/api/destinations"}, {"POST", "/api/destinations"},
+ {"POST", "/api/report/BBCA"}, {"POST", "/api/report/BBCA/ask"},
+ {"GET", "/api/watchlist"}, {"POST", "/api/watchlist"}, {"DELETE", "/api/watchlist/BBCA"},
+ {"GET", "/api/portfolio/risk"}, {"GET", "/api/accuracy"}, {"POST", "/api/chat"},
+ }
+ for _, g := range gated {
+ req := httptest.NewRequest(g.method, g.path, nil) // no session cookie, no demo header
+ rec := httptest.NewRecorder()
+ s.Router().ServeHTTP(rec, req)
+ if rec.Code != http.StatusUnauthorized {
+ t.Errorf("%s %s = %d, want 401", g.method, g.path, rec.Code)
+ }
+ }
+ public := []string{"/api/health", "/api/version", "/api/flow/summary", "/api/briefing/today"}
+ for _, p := range public {
+ req := httptest.NewRequest("GET", p, nil)
+ rec := httptest.NewRecorder()
+ s.Router().ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Errorf("GET %s = %d, want 200", p, rec.Code)
+ }
+ }
+}
+
+func doAuth(t *testing.T, s *Server, method, path string, body any) *httptest.ResponseRecorder {
+ var rdr *bytes.Reader
+ if body != nil {
+ raw, _ := json.Marshal(body)
+ rdr = bytes.NewReader(raw)
+ } else {
+ rdr = bytes.NewReader(nil)
+ }
+ req := httptest.NewRequest(method, path, rdr)
+ u, _ := s.DB.CheckLocalUser("tester", "password1234")
+ if u == nil {
+ var err error
+ u, err = s.DB.CreateLocalUser("tester", "password1234")
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ tok, err := s.DB.CreateSession(u.ID, u.UserKey, time.Hour)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.AddCookie(&http.Cookie{Name: "fs_session", Value: tok})
+ rec := httptest.NewRecorder()
+ s.Router().ServeHTTP(rec, req)
+ return rec
+}
+
+// Signup -> login -> gated route works with cookie; dup/wrong rejected.
+func TestSignupLoginRoundTrip(t *testing.T) {
+ s := testServer(t)
+ rec := do(s, "POST", "/api/auth/signup", map[string]any{"username": "budi", "password": "rahasia123"})
+ if rec.Code != http.StatusCreated {
+ t.Fatalf("signup = %d, body %s", rec.Code, rec.Body.String())
+ }
+ rec = do(s, "POST", "/api/auth/signup", map[string]any{"username": "budi", "password": "rahasia123"})
+ if rec.Code != http.StatusConflict {
+ t.Fatalf("dup signup = %d, want 409", rec.Code)
+ }
+ rec = do(s, "POST", "/api/auth/login", map[string]any{"username": "budi", "password": "salahpass"})
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("wrong login = %d, want 401", rec.Code)
+ }
+ // Fresh signup seeds the "semua" watchlist (fixture tickers BBCA, TLKM).
+ wl, _ := s.DB.Watchlist("u:local:budi")
+ if len(wl) < 2 {
+ t.Fatalf("semua watchlist = %v, want all fixture tickers", wl)
+ }
+ // Version endpoint reports a commit string.
+ rec = do(s, "GET", "/api/version", nil)
+ var v struct {
+ Commit string `json:"commit"`
+ StartedAt string `json:"started_at"`
+ }
+ _ = json.Unmarshal(rec.Body.Bytes(), &v)
+ if rec.Code != http.StatusOK || v.StartedAt == "" {
+ t.Fatalf("version = %d %s", rec.Code, rec.Body.String())
+ }
+ // AllTickers excludes pseudo tickers.
+ all, _ := s.DB.AllTickers()
+ for _, tk := range all {
+ if tk == "IDX" || tk == "ROE" {
+ t.Fatalf("AllTickers leaked pseudo %s", tk)
+ }
+ }
+ if len(all) == 0 {
+ t.Fatal("AllTickers empty on seeded db")
+ }
+}
diff --git a/backend/internal/api/portfolio.go b/backend/internal/api/portfolio.go
index 3122bf5..59b6bc3 100644
--- a/backend/internal/api/portfolio.go
+++ b/backend/internal/api/portfolio.go
@@ -15,7 +15,11 @@ import (
func (s *Server) PortfolioRisk(w http.ResponseWriter, r *http.Request) {
wl, _ := s.DB.Watchlist(s.userKey(r))
if len(wl) == 0 {
- wl = s.Cfg.Watchlist
+ if all, err := s.DB.AllTickers(); err == nil && len(all) > 0 {
+ wl = all
+ } else {
+ wl = s.Cfg.Watchlist
+ }
}
// Concentration: weight by latest close x assumed equal shares (seed-safe).
type bar struct {
diff --git a/backend/internal/api/screen.go b/backend/internal/api/screen.go
index 8579823..f64b575 100644
--- a/backend/internal/api/screen.go
+++ b/backend/internal/api/screen.go
@@ -55,7 +55,11 @@ func (s *Server) Screen(w http.ResponseWriter, r *http.Request) {
if len(universe) == 0 {
universe, _ = s.DB.Watchlist(s.userKey(r))
if len(universe) == 0 {
- universe = s.Cfg.Watchlist
+ if all, err := s.DB.AllTickers(); err == nil && len(all) > 0 {
+ universe = all
+ } else {
+ universe = s.Cfg.Watchlist
+ }
}
}
var rows []ScreenRow
diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go
index 2cffe39..400d69e 100644
--- a/backend/internal/api/server.go
+++ b/backend/internal/api/server.go
@@ -35,6 +35,9 @@ type Server struct {
LLM *llm.Client
Validate *validator.Validate
Hub *Hub
+ // Commit + StartedAt power /api/version (CI anti-stale proof).
+ Commit string
+ StartedAt time.Time
}
// New builds a Server with all dependencies wired.
@@ -45,6 +48,8 @@ func New(cfg config.Config, db *store.DB, cache *store.Cache, s *sectors.Client)
Cfg: cfg, DB: db, Sectors: s, Sched: sched, LLM: llmc,
Validate: validator.New(),
Hub: NewHub(),
+ Commit: readCommit(),
+ StartedAt: time.Now(),
}
srv.Engine = &routines.Engine{DB: db, Notifier: sched.Notifier, UserKey: cfg.DemoUserKey,
Publish: srv.Hub.Publish}
@@ -66,33 +71,41 @@ func (s *Server) Router() http.Handler {
r.Get("/auth/callback", s.AuthCallback)
r.Get("/auth/me", s.AuthMe)
r.Post("/auth/logout", s.AuthLogout)
+ r.Post("/auth/signup", s.AuthSignup)
+ r.Post("/auth/login", s.AuthLogin)
+ r.Get("/version", s.Version)
r.Get("/stream", s.Stream)
+ // Publik baca: dashboard bisa dibuka tanpa login.
r.Get("/flow/summary", s.FlowSummary)
r.Get("/flow/broker", s.FlowBroker)
r.Get("/flow/foreign", s.FlowForeign)
- 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("/briefing/today", s.BriefingToday)
- 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)
+ // Fitur + filter: wajib login (session cookie, tanpa demo bypass).
+ r.Group(func(r chi.Router) {
+ r.Use(s.requireLogin)
+ 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())
diff --git a/backend/internal/api/version.go b/backend/internal/api/version.go
new file mode 100644
index 0000000..7dc3e41
--- /dev/null
+++ b/backend/internal/api/version.go
@@ -0,0 +1,36 @@
+package api
+
+import (
+ "net/http"
+ "os"
+ "strings"
+ "time"
+)
+
+// Version serves GET /api/version: deployed commit + process start time so CI
+// can prove the live process is the freshly deployed one (anti-stale).
+// Commit is resolved once at startup (see readCommit); an old process keeps
+// reporting its old commit even after CI writes a new version.txt.
+func (s *Server) Version(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, http.StatusOK, map[string]any{
+ "commit": s.Commit,
+ "started_at": s.StartedAt.UTC().Format(time.RFC3339),
+ "google_configured": s.Cfg.HasGoogle(),
+ })
+}
+
+// readCommit resolves the deployed commit once at startup: CI writes
+// $GITHUB_SHA to version.txt (WorkingDirectory) before restarting.
+func readCommit() string {
+ if v := strings.TrimSpace(os.Getenv("FLOWSIGHT_COMMIT")); v != "" {
+ return v
+ }
+ for _, p := range []string{"version.txt", "/var/lib/flowsight/version.txt"} {
+ if b, err := os.ReadFile(p); err == nil {
+ if v := strings.TrimSpace(string(b)); v != "" {
+ return v
+ }
+ }
+ }
+ return "unknown"
+}
diff --git a/backend/internal/scheduler/scheduler.go b/backend/internal/scheduler/scheduler.go
index a7ade6e..810d345 100644
--- a/backend/internal/scheduler/scheduler.go
+++ b/backend/internal/scheduler/scheduler.go
@@ -190,12 +190,16 @@ func (s *Scheduler) watchlist() []string {
return s.watchlistFor(s.Cfg.DemoUserKey)
}
-// watchlistFor resolves one owner's watchlist (demo seed fallback kept).
+// watchlistFor resolves one owner's watchlist: personal first, then the
+// "semua" default (every ticker with stored data), then the config list.
func (s *Scheduler) watchlistFor(owner string) []string {
wl, err := s.DB.Watchlist(owner)
if err == nil && len(wl) > 0 {
return wl
}
+ if all, err := s.DB.AllTickers(); err == nil && len(all) > 0 {
+ return all
+ }
if owner == s.Cfg.DemoUserKey && len(s.Cfg.Watchlist) > 0 {
return s.Cfg.Watchlist
}
diff --git a/backend/internal/store/auth.go b/backend/internal/store/auth.go
index cca5893..ce33f26 100644
--- a/backend/internal/store/auth.go
+++ b/backend/internal/store/auth.go
@@ -5,10 +5,24 @@ import (
"encoding/hex"
"strings"
"time"
+
+ "golang.org/x/crypto/bcrypt"
)
// User is one Google-authenticated account. UserKey (`u: