release: alpha2.0 — session persistence, Tanuki stability, login flow

Auth & Session:
- Add credentials: include to all frontend fetch calls (queryClient,
  auth-context, tanuki-trades, symbol-selector, settings, verify)
- Redis-backed session store with connect-redis, 7-day cookie expiry,
  sameSite=lax, httpOnly=true
- Add app.set('trust proxy', 1) so Express honors X-Forwarded-Proto from
  nginx — fixes secure cookies never being sent over HTTPS
- Cookie secure flag also checks APP_URL for reverse proxy deployments

Login Flow:
- Fix post-login redirect race condition: use full page reload
  (window.location.href) instead of hash navigation so auth cookie is
  established before ProtectedRoute checks session
- Disable SymbolSelector query when not authenticated to prevent blocking
  fetches on login page
- SymbolSelector returns null when not authenticated — no UI rendered

Tanuki Trades:
- Add ErrorBoundary component to isolate Tanuki page crashes
- Use unique queryKey ['tanuki-tickers'] to prevent React Query cache
  collision with symbol selector
- Defensive query syntax with async/await for error isolation

Testing:
- Verified login → redirect → refresh → session persists (no 401)
- Verified Tanuki page no longer crashes the entire app
- Verified /api/auth/me returns user data across requests with cookie
master alpha2.0
isnowglobal-admin 2026-07-01 14:53:24 -04:00
parent 14d079bb35
commit fd40f75897
11 changed files with 113 additions and 22 deletions

View File

@ -18,6 +18,7 @@ import ScreenerPage from "@/pages/screener";
import FuturesLevelsPage from "@/pages/futures-levels";
import TanukiTradesPage from "@/pages/tanuki-trades";
import SettingsPage from "@/pages/settings";
import { ErrorBoundary } from "@/components/error-boundary";
import AccountPage from "@/pages/account";
import LoginPage from "@/pages/login";
import VerifyPage from "@/pages/verify";
@ -96,7 +97,11 @@ function AppShell() {
<Route path="/expiry" component={() => <ProtectedRoute component={ExpiryMatrixPage} />} />
<Route path="/screener" component={() => <ProtectedRoute component={ScreenerPage} />} />
<Route path="/futures" component={() => <ProtectedRoute component={FuturesLevelsPage} />} />
<Route path="/tanuki" component={() => <ProtectedRoute component={TanukiTradesPage} />} />
<Route path="/tanuki" component={() => <ProtectedRoute component={() => (
<ErrorBoundary>
<TanukiTradesPage />
</ErrorBoundary>
)} />} />
<Route path="/settings" component={() => <ProtectedRoute component={SettingsPage} />} />
<Route component={NotFound} />
</Switch>

View File

@ -0,0 +1,59 @@
import { Component, ErrorInfo, ReactNode } from "react";
import { AlertTriangle, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("[ErrorBoundary] Caught:", error, errorInfo.componentStack);
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<Card className="m-4">
<CardContent className="flex flex-col items-center justify-center gap-4 p-8 text-center">
<AlertTriangle className="h-10 w-10 text-destructive" />
<div>
<h3 className="text-sm font-semibold">Something went wrong</h3>
<p className="text-xs text-muted-foreground mt-1">
{this.state.error?.message || "An unexpected error occurred"}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => this.setState({ hasError: false, error: null })}
>
<RefreshCw className="h-3 w-3 mr-1" />
Try again
</Button>
</CardContent>
</Card>
);
}
return this.props.children;
}
}

View File

@ -26,7 +26,10 @@ export function SymbolSelector() {
const [showAdd, setShowAdd] = useState(false);
const isAuthenticated = !!user;
const { data } = useQuery<SymbolInfo[]>({ queryKey: ["/api/symbols"] });
const { data } = useQuery<SymbolInfo[]>({
queryKey: ["/api/symbols"],
enabled: isAuthenticated,
});
const symbols = data ?? [];
const builtInSymbols = symbols.filter((s) => !s.custom);
const customSymbols = symbols.filter((s) => s.custom);
@ -37,6 +40,7 @@ export function SymbolSelector() {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
credentials: "include",
});
if (!res.ok) {
const err = await res.json();
@ -54,7 +58,7 @@ export function SymbolSelector() {
const deleteMutation = useMutation({
mutationFn: async (ticker: string) => {
const res = await fetch(`/api/symbols/${ticker}`, { method: "DELETE" });
const res = await fetch(`/api/symbols/${ticker}`, { method: "DELETE", credentials: "include" });
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to delete symbol");

View File

@ -31,7 +31,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [, setLocation] = useLocation();
useEffect(() => {
fetch("/api/auth/me")
fetch("/api/auth/me", { credentials: "include" })
.then((r) => {
if (r.ok) return r.json();
throw new Error("not authenticated");
@ -49,6 +49,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
credentials: "include",
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Authentication failed" }));
@ -57,8 +58,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
const data: User = await res.json();
setUser(data);
// Use full page navigation to dashboard - bypasses hash router issues
window.location.hash = "/";
// Full page reload to ensure auth state is established before ProtectedRoute runs
await new Promise(resolve => setTimeout(resolve, 100));
window.location.href = "/#/";
};
const register = async (name: string, email: string, password: string) => {
@ -67,6 +69,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, password }),
credentials: "include",
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Registration failed" }));
@ -79,7 +82,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
};
const logout = async () => {
await fetch("/api/auth/logout", { method: "POST" });
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
setUser(null);
setLocation("/login");
};
@ -90,6 +93,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
credentials: "include",
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Update failed" }));
@ -107,6 +111,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ currentPassword: current, newPassword: newPass }),
credentials: "include",
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Password change failed" }));
@ -123,6 +128,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
credentials: "include",
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Request failed" }));
@ -139,6 +145,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
credentials: "include",
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Failed to resend" }));

View File

@ -18,6 +18,7 @@ export async function apiRequest(
method,
headers: data ? { "Content-Type": "application/json" } : {},
body: data ? JSON.stringify(data) : undefined,
credentials: "include",
});
await throwIfResNotOk(res);
@ -30,7 +31,9 @@ export const getQueryFn: <T>(options: {
}) => QueryFunction<T> =
({ on401: unauthorizedBehavior }) =>
async ({ queryKey }) => {
const res = await fetch(`${API_BASE}${queryKey.join("/")}`);
const res = await fetch(`${API_BASE}${queryKey.join("/")}`, {
credentials: "include",
});
if (unauthorizedBehavior === "returnNull" && res.status === 401) {
return null;

View File

@ -24,9 +24,10 @@ export default function SettingsPage() {
const saveMutation = useMutation({
mutationFn: async (key: string) => {
const res = await fetch("/api/orats/config", {
method: "POST",
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey: key, baseUrl }),
credentials: "include",
});
if (!res.ok) throw new Error("Failed to save API key");
return res.json();
@ -40,7 +41,7 @@ export default function SettingsPage() {
const clearMutation = useMutation({
mutationFn: async () => {
const res = await fetch("/api/orats/config", { method: "DELETE" });
const res = await fetch("/api/orats/config", { method: "DELETE", credentials: "include" });
if (!res.ok) throw new Error("Failed to clear API key");
return res.json();
},

View File

@ -44,8 +44,8 @@ export default function TanukiTradesPage() {
// Fetch all available symbols
const symbolsQuery = useQuery<string[]>({
queryKey: ["/api/symbols"],
queryFn: () => fetch("/api/symbols").then(r => r.json()).then((data: any[]) =>
queryKey: ["/api/symbols/tickers"],
queryFn: () => fetch("/api/symbols", { credentials: "include" }).then(r => r.json()).then((data: any[]) =>
data.map((s: any) => s.ticker)
),
staleTime: 60 * 1000,
@ -64,18 +64,24 @@ export default function TanukiTradesPage() {
// Single symbol query — uses ORATS-derived levels (fallback when Tanuki not configured)
const tanukiQuery = useQuery<TanukiLevels>({
queryKey: ["/api/tanuki", selectedSymbol],
queryFn: () => fetch(`/api/tanuki/${selectedSymbol}`).then(r => {
if (!r.ok) throw new Error(r.statusText);
queryFn: async () => {
const r = await fetch(`/api/tanuki/${selectedSymbol}`, { credentials: "include" });
if (!r.ok) {
if (r.status === 404) {
return await r.json();
}
throw new Error(`Tanuki fetch failed: ${r.status}`);
}
return r.json();
}),
enabled: !compareMode,
},
enabled: !compareMode && !!selectedSymbol,
staleTime: 2 * 60 * 1000,
});
// Multi-symbol comparison query — uses all symbols
const compareQuery = useQuery<Record<string, TanukiLevels | { error: string }>>({
queryKey: ["/api/tanuki", "compare"],
queryFn: () => fetch(`/api/tanuki?symbols=${allSymbols.join(",")}`).then(r => {
queryFn: () => fetch(`/api/tanuki?symbols=${allSymbols.join(",")}`, { credentials: "include" }).then(r => {
if (!r.ok) throw new Error(r.statusText);
return r.json();
}),
@ -86,14 +92,14 @@ export default function TanukiTradesPage() {
// Status query
const statusQuery = useQuery({
queryKey: ["/api/tanuki/status"],
queryFn: () => fetch("/api/tanuki/status").then(r => r.json()),
queryFn: () => fetch("/api/tanuki/status", { credentials: "include" }).then(r => r.json()),
staleTime: 60 * 1000,
});
const tanukiConfigured = statusQuery.data?.configured;
const oratsStatusQuery = useQuery({
queryKey: ["/api/orats/status"],
queryFn: () => fetch("/api/orats/status").then(r => r.json()),
queryFn: () => fetch("/api/orats/status", { credentials: "include" }).then(r => r.json()),
staleTime: 60 * 1000,
});
const oratsConfigured = oratsStatusQuery.data?.configured;

View File

@ -17,7 +17,7 @@ export default function VerifyPage() {
return;
}
fetch(`/api/auth/verify/${token}`)
fetch(`/api/auth/verify/${token}`, { credentials: "include" })
.then(async (res) => {
const data = await res.json();
if (!res.ok) {

View File

@ -14,6 +14,9 @@ declare module "http" {
}
}
// Trust reverse proxy (nginx) for protocol/headers so session cookies work over HTTPS
app.set("trust proxy", 1);
app.use(
express.json({
verify: (req, _res, buf) => {

View File

@ -95,7 +95,7 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise<
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === "production",
secure: process.env.NODE_ENV === "production" || (process.env.APP_URL || "").startsWith("https"),
httpOnly: true, // Prevent XSS from accessing session cookie
sameSite: "lax", // Allow same-site + top-level navigation
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
@ -106,7 +106,7 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise<
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === "production",
secure: process.env.NODE_ENV === "production" || (process.env.APP_URL || "").startsWith("https"),
httpOnly: true,
sameSite: "lax",
maxAge: 7 * 24 * 60 * 60 * 1000,

3
start-dev.sh Normal file
View File

@ -0,0 +1,3 @@
#!/bin/bash
cd /home/chuck/projects/gammadesk
NODE_ENV=development npx tsx server/index.ts "$@"