From 79f42dfefa7f8bd4f459c6214053b8a7820acf1b Mon Sep 17 00:00:00 2001
From: asepharyana
Date: Tue, 15 Sep 2026 22:12:09 +0700
Subject: [PATCH] feat: default watchlist semua, login-gate fitur, CI
anti-stale via /api/version
---
.github/workflows/ci.yml | 2 +-
backend/cmd/server/main.go | 9 +-
backend/internal/api/api_test.go | 46 +++----
backend/internal/api/auth.go | 122 +++++++++++++++++-
backend/internal/api/flow.go | 6 +-
backend/internal/api/gated_test.go | 109 ++++++++++++++++
backend/internal/api/portfolio.go | 6 +-
backend/internal/api/screen.go | 6 +-
backend/internal/api/server.go | 57 ++++----
backend/internal/api/version.go | 36 ++++++
backend/internal/scheduler/scheduler.go | 6 +-
backend/internal/store/auth.go | 79 +++++++++++-
.../store/migrations/0007_local_auth.sql | 6 +
backend/internal/store/rows.go | 22 ++++
web/src/components/auth.tsx | 26 +++-
web/src/index.tsx | 98 ++++++++++----
web/src/lib/api.ts | 3 +
web/src/pages/Alerts.tsx | 8 +-
web/src/pages/Portfolio.tsx | 8 +-
web/src/pages/Report.tsx | 8 +-
web/src/pages/Routines.tsx | 8 +-
web/src/pages/Screener.tsx | 15 ++-
22 files changed, 602 insertions(+), 84 deletions(-)
create mode 100644 backend/internal/api/gated_test.go
create mode 100644 backend/internal/api/version.go
create mode 100644 backend/internal/store/migrations/0007_local_auth.sql
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:`) is
-// the scoping key used by every user-owned table.
+// the scoping key used by every user-owned table. Local accounts
+// (username+password signup) store google_sub='local:' with
+// username+password_hash set, keeping session scoping unchanged.
+type authErr string
+
+func (e authErr) Error() string { return string(e) }
+
+const (
+ ErrBadLogin authErr = "username atau password salah"
+ ErrBadUsername authErr = "username 3-32 karakter: huruf, angka, titik, _ -"
+ ErrBadPassword authErr = "password minimal 8 karakter"
+ ErrTaken authErr = "username sudah dipakai"
+)
type User struct {
ID int64
GoogleSub string
@@ -108,3 +122,66 @@ func (db *DB) DeleteSession(token string) error {
_, err := db.Exec(`DELETE FROM sessions WHERE token=?`, token)
return err
}
+
+// localSub maps a username to its google_sub value for local accounts.
+func localSub(username string) string { return "local:" + strings.ToLower(username) }
+
+// CreateLocalUser registers a username+password account (bcrypt cost 10).
+// Username: 3-32 chars [a-z0-9._-]; password: min 8 chars.
+func (db *DB) CreateLocalUser(username, password string) (*User, error) {
+ username = strings.ToLower(strings.TrimSpace(username))
+ if !validUsername(username) {
+ return nil, ErrBadUsername
+ }
+ if len(password) < 8 {
+ return nil, ErrBadPassword
+ }
+ hash, err := bcrypt.GenerateFromPassword([]byte(password), 10)
+ if err != nil {
+ return nil, err
+ }
+ sub := localSub(username)
+ now := time.Now().UTC().Format(time.RFC3339)
+ if _, err := db.Exec(`INSERT INTO users(google_sub,email,name,username,password_hash,created_at)
+ VALUES(?,?,?,?,?,?)`, sub, username, username, username, string(hash), now); err != nil {
+ if strings.Contains(err.Error(), "UNIQUE") {
+ return nil, ErrTaken
+ }
+ return nil, err
+ }
+ return db.UserBySub(sub)
+}
+
+// CheckLocalUser verifies username+password, returning the user on success.
+func (db *DB) CheckLocalUser(username, password string) (*User, error) {
+ username = strings.ToLower(strings.TrimSpace(username))
+ var u User
+ var hash string
+ if err := db.QueryRow(`SELECT id,google_sub,email,name,avatar_url,created_at,password_hash
+ FROM users WHERE username=?`, username).
+ Scan(&u.ID, &u.GoogleSub, &u.Email, &u.Name, &u.AvatarURL, &u.CreatedAt, &hash); err != nil {
+ return nil, ErrBadLogin
+ }
+ if hash == "" {
+ return nil, ErrBadLogin // Google-only account, no local password
+ }
+ if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
+ return nil, ErrBadLogin
+ }
+ u.UserKey = UserKeyForSub(u.GoogleSub)
+ return &u, nil
+}
+
+// validUsername allows 3-32 chars of lowercase letters, digits, . _ -.
+func validUsername(u string) bool {
+ if len(u) < 3 || len(u) > 32 {
+ return false
+ }
+ for _, c := range u {
+ if c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '.' || c == '_' || c == '-' {
+ continue
+ }
+ return false
+ }
+ return true
+}
diff --git a/backend/internal/store/migrations/0007_local_auth.sql b/backend/internal/store/migrations/0007_local_auth.sql
new file mode 100644
index 0000000..2f2f767
--- /dev/null
+++ b/backend/internal/store/migrations/0007_local_auth.sql
@@ -0,0 +1,6 @@
+-- FlowSight schema v7: local username+password login (Google stays optional).
+-- Local accounts store google_sub='local:' so session scoping
+-- (sessions join + UserKeyForSub) works unchanged.
+ALTER TABLE users ADD COLUMN username TEXT;
+ALTER TABLE users ADD COLUMN password_hash TEXT NOT NULL DEFAULT '';
+CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username);
diff --git a/backend/internal/store/rows.go b/backend/internal/store/rows.go
index 0f13cd2..c0bab67 100644
--- a/backend/internal/store/rows.go
+++ b/backend/internal/store/rows.go
@@ -540,6 +540,28 @@ func (db *DB) Watchlist(userKey string) ([]string, error) {
return out, rows.Err()
}
+// AllTickers returns every ticker that has snapshot data, excluding pseudo
+// tickers (IDX market-wide rows, ROE registry rows). This is the "semua"
+// default universe: dashboard, screener fallback, and scheduler depth all use
+// it when a user has no personal watchlist.
+func (db *DB) AllTickers() ([]string, error) {
+ rows, err := db.Query(`SELECT DISTINCT ticker FROM snapshots
+ WHERE ticker NOT IN ('IDX','ROE') ORDER BY ticker`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []string
+ for rows.Next() {
+ var t string
+ if err := rows.Scan(&t); err != nil {
+ return nil, err
+ }
+ out = append(out, t)
+ }
+ return out, rows.Err()
+}
+
// AddWatch inserts a ticker (idempotent).
func (db *DB) AddWatch(userKey, ticker string) error {
_, err := db.Exec(`INSERT INTO watchlists(user_key,ticker,added_at) VALUES(?,?,?)
diff --git a/web/src/components/auth.tsx b/web/src/components/auth.tsx
index cd4b3a5..78d3d90 100644
--- a/web/src/components/auth.tsx
+++ b/web/src/components/auth.tsx
@@ -2,6 +2,7 @@ import { createResource, createSignal, Show } from "solid-js";
import { api, type AuthUser } from "../lib/api";
import { Button } from "./ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card";
// Theme: .dark class on (shadcn convention). Default dark.
function theme(): string {
@@ -30,7 +31,30 @@ export function useAuth() {
const [me, { refetch }] = createResource(async (): Promise => {
try { return (await api.me()).user; } catch { return null; }
});
- return { me, refetch };
+ const [version] = createResource(async () => {
+ try { return await api.version(); } catch { return null; }
+ });
+ return { me, refetch, version };
+}
+
+// ButuhLogin: kartu ajakan login untuk halaman fitur yg di-hide bila logout.
+export function ButuhLogin(props: { fitur: string }) {
+ return (
+
+
+
+ π {props.fitur} perlu login
+
+ Biar datanya milik kamu sendiri (watchlist, notifikasi, report) β
+ daftar gratis, cukup username + password.
+
+
+
+
+
+
+
+ );
}
export function AuthButton(props: { me: AuthUser | null | undefined; onLogout: () => void }) {
diff --git a/web/src/index.tsx b/web/src/index.tsx
index 6bc9cc9..dbaf2f1 100644
--- a/web/src/index.tsx
+++ b/web/src/index.tsx
@@ -1,8 +1,8 @@
import { render } from "solid-js/web";
-import { Router, Route, useLocation, useNavigate, useParams, type RouteSectionProps } from "@solidjs/router";
-import { createSignal, Show } from "solid-js";
+import { Router, Route, useLocation, useNavigate, type RouteSectionProps } from "@solidjs/router";
+import { createResource, createSignal, Show } from "solid-js";
import { WatchlistDrawer, ChatSidebar } from "./components/WatchlistChat";
-import { ThemeToggle, useAuth, AuthButton } from "./components/auth";
+import { ThemeToggle, useAuth, AuthButton, ButuhLogin } from "./components/auth";
import { Button } from "./components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./components/ui/card";
import { TextField, TextFieldInput } from "./components/ui/text-field";
@@ -15,7 +15,7 @@ import Report from "./pages/Report";
import Portfolio from "./pages/Portfolio";
import "./styles/globals.css";
-const NAV = [
+const NAV_ALL = [
{ href: "/", icon: "π", label: "Dashboard" },
{ href: "/routines", icon: "ποΈ", label: "Routines" },
{ href: "/screener", icon: "π", label: "Screener" },
@@ -23,14 +23,16 @@ const NAV = [
{ href: "/portfolio", icon: "πΌ", label: "Portfolio" },
];
-function Nav() {
+function Nav(props: { loggedIn: boolean }) {
const loc = useLocation();
+ // Belum login: hanya Dashboard yg terlihat (fitur lain di-hide).
+ const items = () => props.loggedIn ? NAV_ALL : NAV_ALL.slice(0, 1);
return (
}>
+ }>
+ {props.children}
+
+
+ );
+}
+
render(
() => (
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index d4ea0f3..c405de1 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -54,6 +54,9 @@ export const api = {
chat: (message: string) => req<{ answer: string }>(`/api/chat`, { method: "POST", body: JSON.stringify({ message }) }),
me: () => req<{ user: AuthUser }>(`/api/auth/me`),
logout: () => req<{ ok: boolean }>(`/api/auth/logout`, { method: "POST" }),
+ signup: (username: string, password: string) => req<{ user: AuthUser }>(`/api/auth/signup`, { method: "POST", body: JSON.stringify({ username, password }) }),
+ login: (username: string, password: string) => req<{ user: AuthUser }>(`/api/auth/login`, { method: "POST", body: JSON.stringify({ username, password }) }),
+ version: () => req<{ commit: string; started_at: string; google_configured: boolean }>(`/api/version`),
};
// SSE hook helper: subscribe to channels agents|alerts|activity.
export function subscribeSSE(onEvent: (channel: string, data: string) => void): () => void {
diff --git a/web/src/pages/Alerts.tsx b/web/src/pages/Alerts.tsx
index 09d6241..1947792 100644
--- a/web/src/pages/Alerts.tsx
+++ b/web/src/pages/Alerts.tsx
@@ -15,7 +15,7 @@ const TEMPLATES: { label: string; desc: string; rule: Record }[
{ label: "π’ Semua gerakan mencolok", desc: "Skor gabungan di atas ambang", rule: { all: [{ field: "score", op: ">=", value: 60 }] } },
];
-export default function Alerts() {
+function AlertsInner() {
const [alerts, { refetch }] = createResource(() => api.alerts());
const [events, { refetch: refetchEv }] = createResource(() => api.alertEvents("2000-01-01").then((r) => r.events.slice(0, 30)));
const [dests, { refetch: refetchDests }] = createResource(() => api.destinations());
@@ -127,3 +127,9 @@ export default function Alerts() {
);
}
+
+import { Gate } from "../index";
+
+export default function Alerts() {
+ return {AlertsInner()};
+}
diff --git a/web/src/pages/Portfolio.tsx b/web/src/pages/Portfolio.tsx
index 6037bf4..ee971e7 100644
--- a/web/src/pages/Portfolio.tsx
+++ b/web/src/pages/Portfolio.tsx
@@ -9,7 +9,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../co
import { Skeleton } from "../components/ui/skeleton";
Chart.register(...registerables);
-export default function Portfolio() {
+function PortfolioInner() {
const [risk] = createResource(() => api.risk());
const conc = () => risk()?.concentration || [];
const betaN = () => Number(risk()?.beta ?? 1);
@@ -66,3 +66,9 @@ export default function Portfolio() {
);
}
+
+import { Gate } from "../index";
+
+export default function Portfolio() {
+ return {PortfolioInner()};
+}
diff --git a/web/src/pages/Report.tsx b/web/src/pages/Report.tsx
index b42ec53..29a6296 100644
--- a/web/src/pages/Report.tsx
+++ b/web/src/pages/Report.tsx
@@ -10,7 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"
import { Skeleton } from "../components/ui/skeleton";
import { TextField, TextFieldInput } from "../components/ui/text-field";
-export default function Report() {
+function ReportInner() {
const params = useParams();
const [rep, setRep] = createSignal(null);
const [md, setMd] = createSignal("");
@@ -103,3 +103,9 @@ export default function Report() {
);
}
+
+import { Gate } from "../index";
+
+export default function Report() {
+ return {};
+}
diff --git a/web/src/pages/Routines.tsx b/web/src/pages/Routines.tsx
index 4e2e2e8..eaa2c6f 100644
--- a/web/src/pages/Routines.tsx
+++ b/web/src/pages/Routines.tsx
@@ -17,7 +17,7 @@ const MANFAAT: Record = {
"weekend-review": { judul: "π Review mingguan", desc: "Ringkasan seminggu: apa yang terjadi dan pelajaran." },
};
-export default function Routines() {
+function RoutinesInner() {
const [data, { refetch }] = createResource(() => api.routines());
const [runs, { refetch: refetchRuns }] = createResource(() => api.runs().then((r) => r.runs.slice(0, 20)));
const [type_, setType] = createSignal("morning-briefing");
@@ -97,3 +97,9 @@ export default function Routines() {
);
}
+
+import { Gate } from "../index";
+
+export default function Routines() {
+ return {RoutinesInner()};
+}
diff --git a/web/src/pages/Screener.tsx b/web/src/pages/Screener.tsx
index bb364b0..6a839d3 100644
--- a/web/src/pages/Screener.tsx
+++ b/web/src/pages/Screener.tsx
@@ -8,15 +8,16 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../co
import { Skeleton } from "../components/ui/skeleton";
import { useNavigate } from "@solidjs/router";
-// Preset awam -> body /api/screen.
+// Preset awam -> body /api/screen. PRESET_SEMUA = default auto-jalan.
+export const PRESET_SEMUA = { label: "π¦ Semua β urut paling menarik", desc: "Tanpa filter, ranking gabungan", body: { limit: 20 } };
const PRESETS: { label: string; desc: string; body: Record }[] = [
{ label: "π₯ Yang lagi diborong bandar", desc: "Broker besar net-beli besar", body: { institutional: { broker_score_min: 5 }, limit: 20 } },
{ label: "π Yang asing lagi beli", desc: "Uang luar negeri masuk", body: { institutional: { foreign_inflow: true }, limit: 20 } },
{ label: "π΅οΈ Yang orang dalamnya ikut beli", desc: "Insider buying terdeteksi", body: { institutional: { insider_buying: true }, limit: 20 } },
- { label: "π¦ Semua β urut paling menarik", desc: "Tanpa filter, ranking gabungan", body: { limit: 20 } },
+ PRESET_SEMUA,
];
-export default function Screener() {
+function ScreenerInner() {
const navigate = useNavigate();
const [rows, setRows] = createSignal([]);
const [ran, setRan] = createSignal(false);
@@ -31,6 +32,8 @@ export default function Screener() {
} catch (e) { setErr(String(e)); }
finally { setBusy(false); }
}
+ // Default = semua: auto-jalan preset "Semua" sekali saat halaman dibuka.
+ if (!ran() && !busy()) void runPreset(PRESET_SEMUA);
const hasil = () => rows().map((r) => ({ row: r, ...verdictFor(r) }));
return (
@@ -77,3 +80,9 @@ export default function Screener() {
);
}
+
+import { Gate } from "../index";
+
+export default function Screener() {
+ return {ScreenerInner()};
+}