fix: audit round 2 — session cookie Secure kondisional, logout clear cookie, IDOR report owner-scoping (migrasi 0008), health force login-gate, SSE stream login-gate, FE: Gate undefined-flicker, auth-expired global redirect, X-User-Key demo dihapus, error handling alerts/routines/watchlist, chart label, encodeURIComponent, Screener onMount
CI / go test + web typecheck (push) Canceled after 0s
CI / build + deploy (Nix) — flowsight (push) Canceled after 0s

This commit is contained in:
asepharyana
2026-09-15 23:19:56 +07:00
parent 96be4d0922
commit 07ece10474
24 changed files with 160 additions and 91 deletions
+13 -7
View File
@@ -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 (
<Card>
<CardHeader class="pb-2"><CardTitle class="text-base">Watchlist</CardTitle></CardHeader>
<CardContent class="space-y-2">
<div class="flex gap-2">
<TextField class="w-24"><TextFieldInput placeholder="BBCA" value={ticker()} onInput={(e) => setTicker(e.currentTarget.value)} /></TextField>
<label class="sr-only" for="wl-ticker">Tambah ticker</label>
<TextField class="w-24"><TextFieldInput id="wl-ticker" placeholder="BBCA" value={ticker()} onInput={(e) => setTicker(e.currentTarget.value)} /></TextField>
<Button size="sm" onClick={add}>Add</Button>
</div>
<Show when={err()}><p class="text-xs text-destructive">{err()}</p></Show>
<ul class="flex flex-wrap gap-1.5">
<For each={wl()?.watchlist || []}>{(t) => <li><a href={`/report/${t}`}><Badge variant="secondary">{t}</Badge></a> <button class="text-xs text-muted-foreground hover:text-foreground" onClick={() => del(t)}>×</button></li>}</For>
<For each={wl()?.watchlist || []}>{(t) => <li class="flex items-center gap-1"><a href={`/report/${encodeURIComponent(t)}`}><Badge variant="secondary">{t}</Badge></a> <button class="text-xs text-muted-foreground hover:text-foreground" aria-label={`Hapus ${t} dari watchlist`} onClick={() => del(t)}>×</button></li>}</For>
</ul>
</CardContent>
</Card>
@@ -62,10 +67,11 @@ export function ChatSidebar() {
</For>
</div>
<div class="flex gap-2">
<TextField class="flex-1"><TextFieldInput placeholder="Ask about your watchlist…" value={msg()} onInput={(e) => setMsg(e.currentTarget.value)} onKeyDown={(e: KeyboardEvent) => { if (e.key === "Enter") send(); }} /></TextField>
<label class="sr-only" for="chat-msg">Tanya AI</label>
<TextField class="flex-1"><TextFieldInput id="chat-msg" placeholder="Ask about your watchlist…" value={msg()} onInput={(e) => setMsg(e.currentTarget.value)} onKeyDown={(e: KeyboardEvent) => { if (e.key === "Enter") send(); }} /></TextField>
<Button size="sm" onClick={send}>Send</Button>
</div>
</CardContent>
</Card>
);
}
}
+2 -3
View File
@@ -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<AuthUser | null> => {
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 (
<Show when={props.me} fallback={
<a href="/api/auth/start"><Button>Sign in with Google</Button></a>
}>
<Show when={props.me} fallback={<a href="/login"><Button>Masuk / Daftar</Button></a>}>
{(u) => (
<span class="flex items-center gap-2">
<Avatar class="size-8">
+15 -4
View File
@@ -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 (
<div class="flex min-h-screen bg-background text-foreground">
<Nav loggedIn={!!me()} />
@@ -166,7 +177,7 @@ function Shell(props: RouteSectionProps) {
export function Gate(props: { fitur: string; children: import("solid-js").JSX.Element }) {
const { me } = useAuth();
return (
<Show when={me() !== null} fallback={<p class="text-sm text-muted-foreground">Memeriksa login…</p>}>
<Show when={me() !== undefined} fallback={<p class="text-sm text-muted-foreground">Memeriksa login…</p>}>
<Show when={me()} fallback={<ButuhLogin fitur={props.fitur} />}>
{props.children}
</Show>
+16 -7
View File
@@ -1,11 +1,19 @@
// Typed backend client (backend is the only source of truth; web never calls Sectors directly).
const BASE = "";
function headers(): HeadersInit {
return { "Content-Type": "application/json", "X-User-Key": localStorage.getItem("fs-key") || "demo" };
// fs-key is legacy demo identity; cookie auth wins server-side. Keep sending
// session cookie implicitly (same-origin default) — no token in localStorage.
return { "Content-Type": "application/json" };
}
// auth-expired: global signal → Shell refetches /me and redirects to /login.
export const AUTH_EXPIRED_EVENT = "fs:auth-expired";
function notifyAuthExpired() {
try { window.dispatchEvent(new CustomEvent(AUTH_EXPIRED_EVENT)); } catch { /* ignore */ }
}
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const r = await fetch(BASE + path, { ...init, headers: { ...headers(), ...(init?.headers || {}) } });
if (!r.ok) {
if (r.status === 401 && !path.startsWith("/api/auth/")) notifyAuthExpired();
let msg = r.statusText;
try { const e = await r.json(); msg = e?.error?.message || msg; } catch { /* keep status */ }
throw new Error(msg);
@@ -27,12 +35,13 @@ export interface ForeignSeries { dates: string[]; nets: number[]; reversal: bool
export const api = {
health: (force = false) => req<Health>(`/api/health${force ? "?force=1" : ""}`),
flowSummary: () => req<FlowSummary>("/api/flow/summary"),
flowBroker: (ticker: string) => req<{ buyers: unknown[]; sellers: unknown[]; citations: Citation[] }>(`/api/flow/broker?ticker=${ticker}`),
flowForeign: (ticker: string) => req<ForeignSeries>(`/api/flow/foreign?ticker=${ticker}`),
flowBroker: (ticker: string) => req<{ buyers: unknown[]; sellers: unknown[]; citations: Citation[] }>(`/api/flow/broker?ticker=${encodeURIComponent(ticker)}`),
flowForeign: (ticker: string) => req<ForeignSeries>(`/api/flow/foreign?ticker=${encodeURIComponent(ticker)}`),
screen: (body: Record<string, unknown>) => req<{ rows: ScreenRow[]; count: number }>("/api/screen", { method: "POST", body: JSON.stringify(body) }),
routines: () => req<{ routines: Routine[] }>("/api/routines"),
createRoutine: (body: Record<string, unknown>) => req<{ id: number }>("/api/routines", { method: "POST", body: JSON.stringify(body) }),
updateRoutine: (id: number, body: Record<string, unknown>) => req<unknown>(`/api/routines/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
deleteRoutine: (id: number) => req<unknown>(`/api/routines/${id}`, { method: "DELETE" }),
runs: (routine_id?: number) => req<{ runs: Record<string, unknown>[] }>(`/api/routine-runs${routine_id ? `?routine_id=${routine_id}` : ""}`),
briefing: () => req<{ date: string; payload: string; citations: string; narasi?: string }>("/api/briefing/today"),
destinations: () => req<{ destinations: { id: number; kind: string; label: string; enabled: boolean; configured: boolean }[] }>("/api/destinations"),
@@ -43,12 +52,12 @@ export const api = {
createAlert: (body: Record<string, unknown>) => req<{ id: number }>("/api/alerts", { method: "POST", body: JSON.stringify(body) }),
deleteAlert: (id: number) => req<unknown>(`/api/alerts/${id}`, { method: "DELETE" }),
alertEvents: (since = "2000-01-01", ticker = "") => req<{ events: Record<string, unknown>[] }>(`/api/alert-events?since=${since}${ticker ? `&ticker=${ticker}` : ""}`),
report: (ticker: string, profile = "moderate") => req<ReportPayload>(`/api/report/${ticker}?profile=${profile}`, { method: "POST" }),
reportMd: (ticker: string) => req<string>(`/api/report/${ticker}?format=md`, { method: "POST" }),
interrogate: (ticker: string, question: string, report_id?: number) => req<{ answer: string; report_id: number; citations: unknown }>(`/api/report/${ticker}/ask`, { method: "POST", body: JSON.stringify({ question, report_id }) }),
report: (ticker: string, profile = "moderate") => req<ReportPayload>(`/api/report/${encodeURIComponent(ticker)}?profile=${profile}`, { method: "POST" }),
reportMd: (ticker: string) => req<string>(`/api/report/${encodeURIComponent(ticker)}?format=md`, { method: "POST" }),
interrogate: (ticker: string, question: string, report_id?: number) => req<{ answer: string; report_id: number; citations: unknown }>(`/api/report/${encodeURIComponent(ticker)}/ask`, { method: "POST", body: JSON.stringify({ question, report_id }) }),
watchlist: () => req<{ watchlist: string[] }>("/api/watchlist"),
addWatch: (ticker: string) => req<unknown>("/api/watchlist", { method: "POST", body: JSON.stringify({ ticker }) }),
removeWatch: (ticker: string) => req<unknown>(`/api/watchlist/${ticker}`, { method: "DELETE" }),
removeWatch: (ticker: string) => req<unknown>(`/api/watchlist/${encodeURIComponent(ticker)}`, { method: "DELETE" }),
risk: () => req<{ concentration: { ticker: string; sector: string; weight: number }[]; correlation: Record<string, Record<string, number>>; beta: number; warnings: string[] }>("/api/portfolio/risk"),
accuracy: () => req<{ agents: { agent: string; calls: number; resolved: number; hits: number; hit_rate: number }[] }>("/api/accuracy"),
chat: (message: string) => req<{ answer: string }>(`/api/chat`, { method: "POST", body: JSON.stringify({ message }) }),
+7 -4
View File
@@ -28,14 +28,16 @@ function AlertsInner() {
const [dchat, setDchat] = createSignal("");
const [dhook, setDhook] = createSignal("");
const [derr, setDerr] = createSignal("");
const [err, setErr] = createSignal("");
async function create() {
setBusy(true);
try {
await api.createAlert({ name: tpl().label, rule: tpl().rule, channels: channels().split(",").map((c) => c.trim()).filter(Boolean) });
refetch();
} finally { setBusy(false); }
} catch (e) { setErr(String(e)); }
finally { setBusy(false); }
}
async function del(id: number) { await api.deleteAlert(id); refetch(); }
async function del(id: number) { try { await api.deleteAlert(id); refetch(); } catch (e) { setErr(String(e)); } }
async function addDest() {
setDerr("");
try {
@@ -47,11 +49,12 @@ function AlertsInner() {
refetchDests();
} catch (e) { setDerr(String(e)); }
}
async function toggleDest(id: number, enabled: boolean) { await api.updateDestination(id, { enabled: !enabled }); refetchDests(); }
async function delDest(id: number) { await api.deleteDestination(id); refetchDests(); }
async function toggleDest(id: number, enabled: boolean) { try { await api.updateDestination(id, { enabled: !enabled }); refetchDests(); } catch (e) { setDerr(String(e)); } }
async function delDest(id: number) { try { await api.deleteDestination(id); refetchDests(); } catch (e) { setDerr(String(e)); } }
return (
<div class="space-y-4">
<PageHead title="Notifikasi otomatis 🔔" sub="Pilih kejadian yang mau kamu dikabari — tanpa perlu paham angka." />
<Show when={err()}><p class="text-sm text-destructive">{err()}</p></Show>
<Card>
<CardHeader><CardTitle>1. Mau dikabari soal apa?</CardTitle><CardDescription>Pilih satu template <Term kata="alert" />.</CardDescription></CardHeader>
<CardContent class="space-y-3">
+7 -4
View File
@@ -1,6 +1,7 @@
import { createResource, createSignal, For, Show } from "solid-js";
import { createResource, createSignal, createMemo, For, Show } from "solid-js";
import { A, useNavigate } from "@solidjs/router";
import { api } from "../lib/api";
import { useAuth } from "../components/auth";
import { verdictFor, heroSummary, konteksPasar, fmtRp, fmtHarga } from "../lib/awam";
import { Term, VerdictBadge, AlasanBar, IstilahStrip } from "../components/Awam";
import { Badge } from "../components/ui/badge";
@@ -13,10 +14,12 @@ Chart.register(...registerables);
export default function Dashboard() {
const navigate = useNavigate();
const { me } = useAuth();
// screen+alertEvents are login-gated; source = me() so login/logout refetches.
const [flow] = createResource(() => api.flowSummary());
const [screen] = createResource(() => api.screen({ limit: 5 }).catch(() => null));
const [screen] = createResource(me, (user) => (user ? api.screen({ limit: 5 }).catch(() => null) : null));
const [briefing] = createResource(() => api.briefing().catch(() => null));
const [events] = createResource(() => api.alertEvents("2000-01-01").then((r) => r.events.slice(0, 5)).catch(() => []));
const [events] = createResource(me, (user) => (user ? api.alertEvents("2000-01-01").then((r) => r.events.slice(0, 5)).catch(() => []) : []));
const [chartTiker, setChartTiker] = createSignal("BBCA");
const [foreign] = createResource(chartTiker, (t) => api.flowForeign(t).catch(() => null));
@@ -31,7 +34,7 @@ export default function Dashboard() {
const chartData = () => ({
labels: (foreign()?.dates || []) as string[],
datasets: [{
label: `${chartTiker()} — uang asing harian (${fmtRp(0).slice(0, 2)}7700`,
label: `${chartTiker()} — uang asing harian (Rp juta)`,
data: (foreign()?.nets || []) as number[],
borderColor: "#3b82f6",
backgroundColor: "rgba(59,130,246,.15)",
+11 -8
View File
@@ -1,4 +1,4 @@
import { createSignal, For, Show, onMount } from "solid-js";
import { createSignal, createEffect, For, Show, on } from "solid-js";
import { useParams } from "@solidjs/router";
import { api, type ReportPayload } from "../lib/api";
import { Citations } from "../components/Citations";
@@ -19,14 +19,15 @@ function ReportInner() {
const [busy, setBusy] = createSignal(true);
const [asking, setAsking] = createSignal(false);
const [err, setErr] = createSignal("");
const authHeaders = () => ({ "X-User-Key": localStorage.getItem("fs-key") || "demo" });
async function load() {
setErr(""); setBusy(true);
try { setRep(await api.report(params.ticker)); }
catch (e) { setErr(String(e)); }
finally { setBusy(false); }
}
onMount(load);
// Refetch when the ticker param changes (ReportInner stays mounted
// because Gate wraps it, but params.ticker is reactive).
createEffect(on(() => params.ticker, () => load()));
async function exportMd() { setMd(await api.reportMd(params.ticker)); }
async function ask() {
if (!question().trim()) return;
@@ -39,6 +40,8 @@ function ReportInner() {
function dl(url: string, name: string) {
const a = document.createElement("a");
a.href = url; a.download = name; a.click();
// Revoke after a short delay to avoid blob URL leak.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
return (
<div class="space-y-4">
@@ -72,15 +75,15 @@ function ReportInner() {
<CardContent class="flex flex-wrap gap-2">
<Button variant="outline" onClick={exportMd}>Markdown</Button>
<Button variant="outline" onClick={async () => {
const r = await fetch(`/api/report/${params.ticker}?format=html`, { method: "POST", headers: authHeaders() });
dl(URL.createObjectURL(new Blob([await r.text()], { type: "text/html" })), `${params.ticker}-report.html`);
const r = await fetch(`/api/report/${encodeURIComponent(params.ticker)}?format=html`, { method: "POST", credentials: "same-origin" });
if (r.ok) dl(URL.createObjectURL(new Blob([await r.text()], { type: "text/html" })), `${params.ticker}-report.html`);
}}>HTML</Button>
<Button variant="outline" onClick={() => {
dl(URL.createObjectURL(new Blob([JSON.stringify(rep(), null, 1)], { type: "application/json" })), `${params.ticker}-report.json`);
}}>JSON</Button>
<Button variant="outline" onClick={async () => {
const r = await fetch(`/api/report/${params.ticker}?format=pdf`, { method: "POST", headers: authHeaders() });
dl(URL.createObjectURL(await r.blob()), `${params.ticker}-report.pdf`);
const r = await fetch(`/api/report/${encodeURIComponent(params.ticker)}?format=pdf`, { method: "POST", credentials: "same-origin" });
if (r.ok) dl(URL.createObjectURL(await r.blob()), `${params.ticker}-report.pdf`);
}}>PDF</Button>
</CardContent>
</Card>
@@ -107,5 +110,5 @@ function ReportInner() {
import { Gate } from "../index";
export default function Report() {
return <Gate fitur="Report saham">{<ReportInner />}</Gate>;
return <Gate fitur="Report saham"><ReportInner /></Gate>;
}
+7 -3
View File
@@ -22,20 +22,24 @@ function RoutinesInner() {
const [runs, { refetch: refetchRuns }] = createResource(() => api.runs().then((r) => r.runs.slice(0, 20)));
const [type_, setType] = createSignal("morning-briefing");
const [busy, setBusy] = createSignal(false);
const [err, setErr] = createSignal("");
const [briefing] = createResource(() => api.briefing().catch(() => null));
async function subscribe() {
setBusy(true);
try { await api.createRoutine({ type: type_() }); refetch(); }
catch (e) { setErr(String(e)); }
finally { setBusy(false); }
}
async function toggle(id: number, enabled: boolean) { await api.updateRoutine(id, { enabled: !enabled }); refetch(); }
async function toggle(id: number, enabled: boolean) {
try { await api.updateRoutine(id, { enabled: !enabled }); refetch(); } catch (e) { setErr(String(e)); }
}
async function del(id: number) {
await fetch(`/api/routines/${id}`, { method: "DELETE", headers: { "X-User-Key": localStorage.getItem("fs-key") || "demo" } });
refetch();
try { await api.deleteRoutine(id); refetch(); } catch (e) { setErr(String(e)); }
}
return (
<div class="space-y-4">
<PageHead title="Jadwal otomatis 🗓️" sub="Pilih sekali — sistem yang kerja tiap hari. Ini namanya routine." />
<Show when={err()}><p class="text-sm text-destructive">{err()}</p></Show>
<Card>
<CardHeader><CardTitle>Mau dilayani apa?</CardTitle><CardDescription>Jadwal standar sudah diatur (mis. ringkasan jam 07:30) — tidak perlu isi cron.</CardDescription></CardHeader>
<CardContent class="space-y-3">
+3 -3
View File
@@ -1,4 +1,4 @@
import { createSignal, For, Show } from "solid-js";
import { createSignal, For, Show, onMount } from "solid-js";
import { api, type ScreenRow } from "../lib/api";
import { verdictFor } from "../lib/awam";
import { Term, VerdictBadge, AlasanBar, IstilahStrip } from "../components/Awam";
@@ -33,7 +33,7 @@ function ScreenerInner() {
finally { setBusy(false); }
}
// Default = semua: auto-jalan preset "Semua" sekali saat halaman dibuka.
if (!ran() && !busy()) void runPreset(PRESET_SEMUA);
onMount(() => { if (!ran()) void runPreset(PRESET_SEMUA); });
const hasil = () => rows().map((r) => ({ row: r, ...verdictFor(r) }));
return (
<div class="space-y-4">
@@ -84,5 +84,5 @@ function ScreenerInner() {
import { Gate } from "../index";
export default function Screener() {
return <Gate fitur="Cari saham">{ScreenerInner()}</Gate>;
return <Gate fitur="Cari saham"><ScreenerInner /></Gate>;
}