diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go
index 61548d3..ea3a5ad 100644
--- a/backend/internal/api/api_test.go
+++ b/backend/internal/api/api_test.go
@@ -66,7 +66,7 @@ func TestBriefing(t *testing.T) {
if rec.Code == http.StatusNotFound {
// No briefing yet: run the routine via engine path instead.
u, _ := s.DB.CheckLocalUser("tester", "password1234")
- rows, _ := s.DB.ListRoutines(u.UserKey)
+ rows, _ := s.DB.ListRoutines(u.UserKey)
if len(rows) == 0 {
t.Fatal("seed has no routines")
}
diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go
index 4c58413..33e5cf4 100644
--- a/backend/internal/api/auth.go
+++ b/backend/internal/api/auth.go
@@ -94,7 +94,7 @@ func (s *Server) AuthCallback(w http.ResponseWriter, r *http.Request) {
}
http.SetCookie(w, &http.Cookie{
Name: "fs_session", Value: tok, Path: "/", HttpOnly: true,
- Secure: true, SameSite: http.SameSiteLaxMode,
+ Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(sessionTTL),
})
http.Redirect(w, r, "/", http.StatusFound)
@@ -123,6 +123,7 @@ func (s *Server) AuthLogout(w http.ResponseWriter, r *http.Request) {
}
http.SetCookie(w, &http.Cookie{
Name: "fs_session", Value: "", Path: "/", HttpOnly: true,
+ Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
@@ -162,8 +163,16 @@ func localCreds(r *http.Request) (string, string, bool) {
return strings.ToLower(strings.TrimSpace(req.Username)), req.Password, true
}
+// isHTTPS checks X-Forwarded-Proto (Caddy) or raw TLS.
+func isHTTPS(r *http.Request) bool {
+ if strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
+ return true
+ }
+ return r.TLS != nil
+}
+
// mintSession creates a session + sets the fs_session cookie.
-func (s *Server) mintSession(w http.ResponseWriter, user *store.User) bool {
+func (s *Server) mintSession(w http.ResponseWriter, r *http.Request, user *store.User) bool {
tok, err := s.DB.CreateSession(user.ID, user.UserKey, sessionTTL)
if err != nil {
writeErr(w, http.StatusBadGateway, "session store unavailable")
@@ -171,7 +180,7 @@ func (s *Server) mintSession(w http.ResponseWriter, user *store.User) bool {
}
http.SetCookie(w, &http.Cookie{
Name: "fs_session", Value: tok, Path: "/", HttpOnly: true,
- Secure: true, SameSite: http.SameSiteLaxMode,
+ Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(sessionTTL),
})
return true
@@ -225,7 +234,7 @@ func (s *Server) AuthSignup(w http.ResponseWriter, r *http.Request) {
return
}
s.seedWatchlistSemua(user.UserKey)
- if !s.mintSession(w, user) {
+ if !s.mintSession(w, r, user) {
return
}
writeJSON(w, http.StatusCreated, map[string]any{"user": s.userJSON(user)})
@@ -245,7 +254,7 @@ func (s *Server) AuthLogin(w http.ResponseWriter, r *http.Request) {
return
}
s.seedWatchlistSemua(user.UserKey)
- if !s.mintSession(w, user) {
+ if !s.mintSession(w, r, user) {
return
}
writeJSON(w, http.StatusOK, map[string]any{"user": s.userJSON(user)})
diff --git a/backend/internal/api/chat.go b/backend/internal/api/chat.go
index 5b6fefc..a1560be 100644
--- a/backend/internal/api/chat.go
+++ b/backend/internal/api/chat.go
@@ -28,13 +28,13 @@ func (s *Server) Chat(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusUnprocessableEntity, "message is required")
return
}
- // Scope grounding: report citations when scoped.
+ // Scope grounding: report citations when scoped (owner-scoped).
var ground, citesRaw string
if req.Scope != nil && req.Scope.ReportID > 0 {
var cites string
var at string
- err := s.DB.QueryRow(`SELECT payload_json, citations_json, generated_at FROM reports WHERE id=?`,
- req.Scope.ReportID).Scan(&ground, &cites, &at)
+ err := s.DB.QueryRow(`SELECT payload_json, citations_json, generated_at FROM reports WHERE id=? AND user_key=?`,
+ req.Scope.ReportID, s.userKey(r)).Scan(&ground, &cites, &at)
if err != nil {
writeErr(w, http.StatusNotFound, "report not found")
return
diff --git a/backend/internal/api/flow.go b/backend/internal/api/flow.go
index 2537898..878acb9 100644
--- a/backend/internal/api/flow.go
+++ b/backend/internal/api/flow.go
@@ -140,7 +140,7 @@ func (s *Server) FlowForeign(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"ticker": q.Ticker, "dates": dates, "nets": nets, "reversal": reversal,
"citations": []model.Citation{model.Cite("v2/foreign-flow/"+q.Ticker+"/", q.Ticker, dates[len(dates)-1])},
- "start": startOf(dates), "end": dates[len(dates)-1],
+ "start": startOf(dates), "end": dates[len(dates)-1],
})
}
diff --git a/backend/internal/api/gated_test.go b/backend/internal/api/gated_test.go
index d807253..b0b5e50 100644
--- a/backend/internal/api/gated_test.go
+++ b/backend/internal/api/gated_test.go
@@ -3,10 +3,10 @@ package api
import (
"bytes"
"encoding/json"
- "time"
"net/http"
"net/http/httptest"
"testing"
+ "time"
)
// Gated routes 401 without session; public routes stay 200.
diff --git a/backend/internal/api/health.go b/backend/internal/api/health.go
index 5be0ac8..ca0d58a 100644
--- a/backend/internal/api/health.go
+++ b/backend/internal/api/health.go
@@ -8,9 +8,14 @@ import (
)
// Health serves GET /api/health: last cycle time + credits spent today +
-// scheduler state + stale flags (docs/API.md).
+// scheduler state + stale flags (docs/API.md). force=1 runs a probe cycle —
+// only for logged-in users (anon force would burn Sectors credits = DoS).
func (s *Server) Health(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("force") == "1" {
+ if _, ok := s.sessionUser(r); !ok {
+ writeErr(w, http.StatusUnauthorized, "login dulu untuk memaksa siklus")
+ return
+ }
_ = s.Sched.RunCycle(r.Context()) // synchronous probe cycle
}
lastCycle, schedOK := s.Sched.Status()
diff --git a/backend/internal/api/interrogate.go b/backend/internal/api/interrogate.go
index 866a876..14826f7 100644
--- a/backend/internal/api/interrogate.go
+++ b/backend/internal/api/interrogate.go
@@ -26,7 +26,7 @@ func (s *Server) Interrogate(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusUnprocessableEntity, "question is required")
return
}
- payload, citesRaw, at, id, err := s.loadReport(ticker, req.ReportID)
+ payload, citesRaw, at, id, err := s.loadReport(ticker, req.ReportID, s.userKey(r))
if err != nil {
writeErr(w, http.StatusNotFound, "no report for "+ticker+" yet — POST /api/report/"+ticker+" first")
return
@@ -49,13 +49,13 @@ func (s *Server) Interrogate(w http.ResponseWriter, r *http.Request) {
}
// loadReport fetches (payload, citations, generated_at, id) for an explicit
-// report id or the latest report for a ticker.
-func (s *Server) loadReport(ticker string, id int64) (string, string, string, int64, error) {
+// report id (scoped to the caller's userKey) or the latest report for a ticker.
+func (s *Server) loadReport(ticker string, id int64, userKey string) (string, string, string, int64, error) {
if id > 0 {
var t, p, c, at string
var rid int64
err := s.DB.QueryRow(`SELECT id, ticker, payload_json, citations_json, generated_at
- FROM reports WHERE id=?`, id).Scan(&rid, &t, &p, &c, &at)
+ FROM reports WHERE id=? AND user_key=?`, id, userKey).Scan(&rid, &t, &p, &c, &at)
if err != nil {
return "", "", "", 0, err
}
@@ -69,8 +69,8 @@ func (s *Server) loadReport(ticker string, id int64) (string, string, string, in
return "", "", "", 0, err
}
var rid int64
- _ = s.DB.QueryRow(`SELECT id FROM reports WHERE ticker=? ORDER BY id DESC LIMIT 1`,
- ticker).Scan(&rid)
+ _ = s.DB.QueryRow(`SELECT id FROM reports WHERE ticker=? AND user_key=? ORDER BY id DESC LIMIT 1`,
+ ticker, userKey).Scan(&rid)
return p, c, at, rid, nil
}
diff --git a/backend/internal/api/report.go b/backend/internal/api/report.go
index adb22d0..88c26db 100644
--- a/backend/internal/api/report.go
+++ b/backend/internal/api/report.go
@@ -19,7 +19,7 @@ func (s *Server) BuildReport(w http.ResponseWriter, r *http.Request) {
if profile == "" {
profile = "moderate"
}
- rep, id, err := s.Builder.Build(r.Context(), ticker, profile)
+ rep, id, err := s.Builder.Build(r.Context(), ticker, profile, s.userKey(r))
if err != nil {
writeErr(w, http.StatusBadGateway, "report: "+err.Error())
return
diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go
index e9399dd..2aa9e4c 100644
--- a/backend/internal/api/server.go
+++ b/backend/internal/api/server.go
@@ -45,8 +45,8 @@ func New(cfg config.Config, db *store.DB, cache *store.Cache, s *sectors.Client)
sched := scheduler.New(cfg, db, cache, s)
srv := &Server{
Cfg: cfg, DB: db, Sectors: s, Sched: sched, LLM: llmc,
- Validate: validator.New(),
- Hub: NewHub(),
+ Validate: validator.New(),
+ Hub: NewHub(),
StartedAt: time.Now(),
}
srv.Engine = &routines.Engine{DB: db, Notifier: sched.Notifier, UserKey: cfg.DemoUserKey,
@@ -72,15 +72,15 @@ func (s *Server) Router() http.Handler {
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.
+ // 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)
- // Fitur + filter: wajib login (session cookie, tanpa demo bypass).
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)
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
index b07fc9b..b197c2a 100644
--- a/backend/internal/config/config.go
+++ b/backend/internal/config/config.go
@@ -81,23 +81,23 @@ func Load() Config {
}
}
return Config{
- Port: getenv("PORT", "8080"),
- SectorsAPIKey: os.Getenv("SECTORS_API_KEY"),
- SectorsBaseURL: getenv("SECTORS_BASE_URL", "https://api.sectors.app/v2/"),
- DBPath: getenv("DB_PATH", "data/flowsight.db"),
- RedisURL: os.Getenv("REDIS_URL"),
- DemoUserKey: getenv("DEMO_USER_KEY", "demo"),
- LLMBaseURL: os.Getenv("LLM_BASE_URL"),
- LLMAPIKey: os.Getenv("LLM_API_KEY"),
- LLMTriage: getenv("LLM_MODEL_TRIAGE", "gpt-4o-mini"),
- LLMSynth: getenv("LLM_MODEL_SYNTH", "gpt-4o"),
- TelegramBotToken: os.Getenv("TELEGRAM_BOT_TOKEN"),
- TelegramChatID: os.Getenv("TELEGRAM_CHAT_ID"),
- DiscordWebhookURL: os.Getenv("DISCORD_WEBHOOK_URL"),
- CreditCapPerCycle: getenvInt("CREDIT_CAP_PER_CYCLE", 120),
- Watchlist: tickers,
- StaticDir: os.Getenv("WEB_DIST_DIR"),
- GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"),
+ Port: getenv("PORT", "8080"),
+ SectorsAPIKey: os.Getenv("SECTORS_API_KEY"),
+ SectorsBaseURL: getenv("SECTORS_BASE_URL", "https://api.sectors.app/v2/"),
+ DBPath: getenv("DB_PATH", "data/flowsight.db"),
+ RedisURL: os.Getenv("REDIS_URL"),
+ DemoUserKey: getenv("DEMO_USER_KEY", "demo"),
+ LLMBaseURL: os.Getenv("LLM_BASE_URL"),
+ LLMAPIKey: os.Getenv("LLM_API_KEY"),
+ LLMTriage: getenv("LLM_MODEL_TRIAGE", "gpt-4o-mini"),
+ LLMSynth: getenv("LLM_MODEL_SYNTH", "gpt-4o"),
+ TelegramBotToken: os.Getenv("TELEGRAM_BOT_TOKEN"),
+ TelegramChatID: os.Getenv("TELEGRAM_CHAT_ID"),
+ DiscordWebhookURL: os.Getenv("DISCORD_WEBHOOK_URL"),
+ CreditCapPerCycle: getenvInt("CREDIT_CAP_PER_CYCLE", 120),
+ Watchlist: tickers,
+ StaticDir: os.Getenv("WEB_DIST_DIR"),
+ GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"),
GoogleClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
GoogleRedirectURL: os.Getenv("GOOGLE_REDIRECT_URL"),
}
diff --git a/backend/internal/reports/report.go b/backend/internal/reports/report.go
index 9bbd540..812adf1 100644
--- a/backend/internal/reports/report.go
+++ b/backend/internal/reports/report.go
@@ -47,7 +47,7 @@ type RiskFn func(ticker string) (concentration, beta string)
// Build runs A1..A6 + A7 and assembles all 7 sections, returning the
// persisted report id for citation-scoped chat interrogation.
-func (b *Builder) Build(ctx context.Context, ticker, profile string) (Report, int64, error) {
+func (b *Builder) Build(ctx context.Context, ticker, profile, userKey string) (Report, int64, error) {
ticker = strings.ToUpper(ticker)
results := agents.RunAll(ctx, b.Deps, ticker)
synth := agents.Synthesize(ctx, b.Deps, ticker, agents.RiskProfile(profile), results)
@@ -79,7 +79,7 @@ func (b *Builder) Build(ctx context.Context, ticker, profile string) (Report, in
narasi := b.narasiAwam(ctx, ticker, synth, sections)
raw, _ := json.Marshal(map[string]any{"ticker": ticker, "sections": sections, "synthesis": synth, "narasi_awam": narasi})
citesRaw, _ := json.Marshal(all)
- id, err := b.DB.SaveReport(ticker, string(raw), string(citesRaw))
+ id, err := b.DB.SaveReport(ticker, string(raw), string(citesRaw), userKey)
if err != nil {
return Report{}, 0, err
}
diff --git a/backend/internal/store/auth.go b/backend/internal/store/auth.go
index ce33f26..86a0368 100644
--- a/backend/internal/store/auth.go
+++ b/backend/internal/store/auth.go
@@ -23,6 +23,7 @@ const (
ErrBadPassword authErr = "password minimal 8 karakter"
ErrTaken authErr = "username sudah dipakai"
)
+
type User struct {
ID int64
GoogleSub string
diff --git a/backend/internal/store/migrations/0008_reports_owner.sql b/backend/internal/store/migrations/0008_reports_owner.sql
new file mode 100644
index 0000000..1ae72b2
--- /dev/null
+++ b/backend/internal/store/migrations/0008_reports_owner.sql
@@ -0,0 +1,16 @@
+-- 0008_reports_owner.sql: reports now scoped to the owning user.
+-- Existing rows are assigned to the shared demo key so historic reports
+-- remain readable by the demo/seed pipeline; new reports carry user_key.
+CREATE TABLE IF NOT EXISTS reports_new(
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ ticker TEXT NOT NULL, generated_at TEXT NOT NULL,
+ payload_json TEXT NOT NULL, citations_json TEXT NOT NULL DEFAULT '[]',
+ user_key TEXT NOT NULL DEFAULT 'demo'
+);
+INSERT INTO reports_new(id, ticker, generated_at, payload_json, citations_json, user_key)
+ SELECT id, ticker, generated_at, payload_json, citations_json, 'demo' FROM reports;
+DROP TABLE reports;
+ALTER TABLE reports_new RENAME TO reports;
+CREATE INDEX IF NOT EXISTS idx_reports_ticker ON reports(ticker, id);
+
+-- SQLite ignores IF NOT EXISTS on column add; guard on table existence.
\ No newline at end of file
diff --git a/backend/internal/store/rows.go b/backend/internal/store/rows.go
index c0bab67..f4df9e1 100644
--- a/backend/internal/store/rows.go
+++ b/backend/internal/store/rows.go
@@ -611,10 +611,10 @@ func (db *DB) FilingsSince(ticker, since string, limit int) ([]map[string]any, e
// Reports reports / briefings.
-// SaveReport stores a generated report; returns its id.
-func (db *DB) SaveReport(ticker, payload, cites string) (int64, error) {
- res, err := db.Exec(`INSERT INTO reports(ticker,generated_at,payload_json,citations_json)
- VALUES(?,?,?,?)`, ticker, time.Now().UTC().Format(time.RFC3339), payload, cites)
+// SaveReport stores a generated report owned by userKey; returns its id.
+func (db *DB) SaveReport(ticker, payload, cites, userKey string) (int64, error) {
+ res, err := db.Exec(`INSERT INTO reports(ticker,generated_at,payload_json,citations_json,user_key)
+ VALUES(?,?,?,?,?)`, ticker, time.Now().UTC().Format(time.RFC3339), payload, cites, userKey)
if err != nil {
return 0, err
}
diff --git a/backend/internal/store/rows_extra_test.go b/backend/internal/store/rows_extra_test.go
index 663b0fd..7354d8f 100644
--- a/backend/internal/store/rows_extra_test.go
+++ b/backend/internal/store/rows_extra_test.go
@@ -50,7 +50,7 @@ func TestStoreParity(t *testing.T) {
if err != nil || len(fils) != 1 {
t.Fatalf("filings = %v, %v", fils, err)
}
- _, _ = db.SaveReport("BBCA", `{"a":1}`, `[]`)
+ _, _ = db.SaveReport("BBCA", `{"a":1}`, `[]`, "tester")
reps, err := db.ListReports("BBCA", 10)
if err != nil || len(reps) != 1 {
t.Fatalf("reports = %v, %v", reps, err)
diff --git a/web/src/components/WatchlistChat.tsx b/web/src/components/WatchlistChat.tsx
index e4b7a25..af08ed1 100644
--- a/web/src/components/WatchlistChat.tsx
+++ b/web/src/components/WatchlistChat.tsx
@@ -11,27 +11,32 @@ export function WatchlistDrawer() {
const [err, setErr] = createSignal("");
async function add() {
setErr("");
+ const t = ticker().toUpperCase().trim();
+ if (!t) return;
try {
- await api.addWatch(ticker().toUpperCase().trim());
+ await api.addWatch(t);
setTicker("");
refetch();
} catch (e) { setErr(String(e)); }
}
async function del(t: string) {
- await api.removeWatch(t);
- refetch();
+ try {
+ await api.removeWatch(t);
+ refetch();
+ } catch (e) { setErr(String(e)); }
}
return (
{err()}
{err()}
{err()}