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 ( Watchlist
- setTicker(e.currentTarget.value)} /> + + setTicker(e.currentTarget.value)} />

{err()}

@@ -62,10 +67,11 @@ export function ChatSidebar() {
- setMsg(e.currentTarget.value)} onKeyDown={(e: KeyboardEvent) => { if (e.key === "Enter") send(); }} /> + + setMsg(e.currentTarget.value)} onKeyDown={(e: KeyboardEvent) => { if (e.key === "Enter") send(); }} />
); -} +} \ No newline at end of file diff --git a/web/src/components/auth.tsx b/web/src/components/auth.tsx index 78d3d90..65fb70f 100644 --- a/web/src/components/auth.tsx +++ b/web/src/components/auth.tsx @@ -28,6 +28,7 @@ export function ThemeToggle() { } export function useAuth() { + // me() is undefined while loading, null when logged out, AuthUser when in. const [me, { refetch }] = createResource(async (): Promise => { try { return (await api.me()).user; } catch { return null; } }); @@ -59,9 +60,7 @@ export function ButuhLogin(props: { fitur: string }) { export function AuthButton(props: { me: AuthUser | null | undefined; onLogout: () => void }) { return ( - - }> + }> {(u) => ( diff --git a/web/src/index.tsx b/web/src/index.tsx index dbaf2f1..72053d8 100644 --- a/web/src/index.tsx +++ b/web/src/index.tsx @@ -1,12 +1,12 @@ import { render } from "solid-js/web"; import { Router, Route, useLocation, useNavigate, type RouteSectionProps } from "@solidjs/router"; -import { createResource, createSignal, Show } from "solid-js"; +import { createResource, createSignal, createEffect, Show } from "solid-js"; import { WatchlistDrawer, ChatSidebar } from "./components/WatchlistChat"; 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"; -import { api } from "./lib/api"; +import { api, AUTH_EXPIRED_EVENT } from "./lib/api"; import Dashboard from "./pages/Dashboard"; import Routines from "./pages/Routines"; import Screener from "./pages/Screener"; @@ -134,7 +134,18 @@ function LoginPage() { function Shell(props: RouteSectionProps) { const { me, refetch } = useAuth(); - const logout = async () => { await api.logout(); refetch(); }; + const nav = useNavigate(); + // Any 401 on a gated API while logged in → session expired; go to login. + createEffect(() => { + const h = () => { if (me()) { refetch(); nav("/login"); } }; + window.addEventListener(AUTH_EXPIRED_EVENT, h); + return () => window.removeEventListener(AUTH_EXPIRED_EVENT, h); + }); + const logout = async () => { + try { await api.logout(); } catch { /* server gone — clear locally anyway */ } + refetch(); + nav("/"); + }; return (