From d46a2916e20534d2090fb1322dbfcdc37957b523 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 12 Jun 2026 09:30:43 +0000 Subject: [PATCH 1/2] fix(security): clear b1dz localStorage on sign-out Logout is a server form POST (clears the session cookie) and can't touch localStorage, so cached client state (b1dz:source-state:*) lingered for the next account on a shared browser. New SignOutForm client component wipes all `b1dz:*` localStorage keys on submit, then performs the normal server logout. Applied to all sign-out buttons (home, dashboard, projections, console, store, settings). Defense-in-depth on top of per-user cache keying. Co-Authored-By: Claude Opus 4.8 --- apps/web/src/app/console/page.tsx | 5 ++-- apps/web/src/app/dashboard/page.tsx | 5 ++-- .../src/app/dashboard/projections/page.tsx | 5 ++-- apps/web/src/app/page.tsx | 5 ++-- apps/web/src/app/settings/page.tsx | 5 ++-- apps/web/src/app/store/page.tsx | 5 ++-- apps/web/src/components/sign-out-form.tsx | 23 +++++++++++++++++++ 7 files changed, 35 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/components/sign-out-form.tsx diff --git a/apps/web/src/app/console/page.tsx b/apps/web/src/app/console/page.tsx index 55e4656..ac6c207 100644 --- a/apps/web/src/app/console/page.tsx +++ b/apps/web/src/app/console/page.tsx @@ -4,6 +4,7 @@ import Image from 'next/image'; import { createServerSupabase } from '@/lib/supabase'; import { ConsoleClient } from './console-client'; import { RenewalBanner } from '@/components/renewal-banner'; +import { SignOutForm } from '@/components/sign-out-form'; export const dynamic = 'force-dynamic'; @@ -26,9 +27,7 @@ export default async function ConsolePage() { Store Settings {user.email} -
- -
+ diff --git a/apps/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx index 5d88a0a..96bf289 100644 --- a/apps/web/src/app/dashboard/page.tsx +++ b/apps/web/src/app/dashboard/page.tsx @@ -6,6 +6,7 @@ import { TradingChart } from './trading-chart'; import { DashboardSummary } from './dashboard-summary'; import { GrowthProjection } from './growth-projection'; import { RenewalBanner } from '@/components/renewal-banner'; +import { SignOutForm } from '@/components/sign-out-form'; export const dynamic = 'force-dynamic'; @@ -29,9 +30,7 @@ export default async function DashboardPage() { Console → Settings {user.email} -
- -
+ diff --git a/apps/web/src/app/dashboard/projections/page.tsx b/apps/web/src/app/dashboard/projections/page.tsx index f7d07fb..eda51b5 100644 --- a/apps/web/src/app/dashboard/projections/page.tsx +++ b/apps/web/src/app/dashboard/projections/page.tsx @@ -3,6 +3,7 @@ import { redirect } from 'next/navigation'; import Link from 'next/link'; import Image from 'next/image'; import { ProjectionsClient } from './projections-client'; +import { SignOutForm } from '@/components/sign-out-form'; export const dynamic = 'force-dynamic'; @@ -25,9 +26,7 @@ export default async function ProjectionsPage() { Console → Settings {user.email} -
- -
+ diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index cfb6d3f..3db21d9 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -6,6 +6,7 @@ import { UniswapLogo, ZeroExLogo, OneInchLogo, JupiterLogo, PumpFunLogo, UniswapV4Logo, PancakeSwapLogo, RaydiumLogo, OrcaLogo, AerodromeLogo, } from './_components/brand-logos'; +import { SignOutForm } from '@/components/sign-out-form'; export const dynamic = 'force-dynamic'; @@ -127,9 +128,7 @@ export default async function LandingPage() { Console Settings {user.email} -
- -
+ ) : ( <> diff --git a/apps/web/src/app/settings/page.tsx b/apps/web/src/app/settings/page.tsx index 716a483..ef4fc43 100644 --- a/apps/web/src/app/settings/page.tsx +++ b/apps/web/src/app/settings/page.tsx @@ -3,6 +3,7 @@ import Link from 'next/link'; import Image from 'next/image'; import { createServerSupabase } from '@/lib/supabase'; import { SettingsClient } from './settings-client'; +import { SignOutForm } from '@/components/sign-out-form'; export const dynamic = 'force-dynamic'; @@ -24,9 +25,7 @@ export default async function SettingsPage() {
{user.email} -
- -
+
diff --git a/apps/web/src/app/store/page.tsx b/apps/web/src/app/store/page.tsx index c7e5cd2..0d269f1 100644 --- a/apps/web/src/app/store/page.tsx +++ b/apps/web/src/app/store/page.tsx @@ -5,6 +5,7 @@ import { PLUGIN_CATALOG, type CatalogEntry } from '@b1dz/core'; import { createServerSupabase } from '@/lib/supabase'; import { coinpayConfigured } from '@/lib/coinpay-client'; import { InstallButton } from './install-button'; +import { SignOutForm } from '@/components/sign-out-form'; export const metadata: Metadata = { title: 'b1dz Store — Plugin Marketplace', @@ -78,9 +79,7 @@ export default async function StorePage() { Dashboard Settings {user.email} -
- -
+ ) : ( <> diff --git a/apps/web/src/components/sign-out-form.tsx b/apps/web/src/components/sign-out-form.tsx new file mode 100644 index 0000000..725ee82 --- /dev/null +++ b/apps/web/src/components/sign-out-form.tsx @@ -0,0 +1,23 @@ +'use client'; + +/** + * Sign-out button that clears this browser's b1dz client-side cache before the + * server logout. Logout is a server form POST (clears the session cookie), which + * can't touch localStorage — so without this, cached dashboard state (e.g. + * b1dz:source-state:*) would linger for the next account on a shared browser. + * Defense-in-depth on top of the per-user cache keying. + */ +export function SignOutForm({ className }: { className?: string }) { + const clearClientCache = () => { + try { + for (const key of Object.keys(window.localStorage)) { + if (key.startsWith('b1dz:')) window.localStorage.removeItem(key); + } + } catch { /* private mode / quota */ } + }; + return ( +
+ +
+ ); +} From c8d2a8caf1ceab36fa03f7dfb946671d091d1665 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 12 Jun 2026 09:49:08 +0000 Subject: [PATCH 2/2] fix(ci): green typecheck + test across the monorepo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new sh1pt CI workflows run `pnpm typecheck` + `pnpm test` on the whole monorepo, which surfaced pre-existing failures (and one I'd just introduced): - web signup route: `data.user` possibly null after the obfuscation guard — add an explicit null check (was a real type hole vitest didn't catch). - core stripLiveSourceState: strip `prices` (fat live data); update the stale test to expect `tradeState` KEPT (intentional — closedTrades history). - cli statusFreshness: tests hardcoded the old 10s stale threshold; make them derive from TRADE_STALE_AFTER_MS (now 30s) so they're robust. - dealdash decide.test: `find` predicates weren't type guards, so cancel/book helpers returned the full Decision union (alert variant lacks `reason`) — narrow them with `d is Extract`. typecheck 64/64, test 64/64. Co-Authored-By: Claude Opus 4.8 --- apps/cli/src/tui/statusFreshness.test.ts | 10 ++++++---- apps/web/src/app/api/auth/signup/route.ts | 2 ++ packages/core/src/runtime-cache.test.ts | 6 ++++-- packages/core/src/runtime-cache.ts | 1 + packages/source-dealdash/src/strategy/decide.test.ts | 4 ++-- 5 files changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/tui/statusFreshness.test.ts b/apps/cli/src/tui/statusFreshness.test.ts index 4187d1d..d450307 100644 --- a/apps/cli/src/tui/statusFreshness.test.ts +++ b/apps/cli/src/tui/statusFreshness.test.ts @@ -207,22 +207,24 @@ describe('computeStatusFreshness — stale payload', () => { it('badges stale once age exceeds the threshold', () => { const now = 1_700_000_000_000; + const ageMs = TRADE_STALE_AFTER_MS + 42_000; // well past the threshold const out = computeStatusFreshness({ ...BASE, dataLoading: false, - lastTickMs: now - (TRADE_STALE_AFTER_MS + 42_000), // 52s past last tick + lastTickMs: now - ageMs, nowMs: now, }); + const sec = Math.round(ageMs / 1000); expect(out.isStale).toBe(true); - expect(out.staleSec).toBe(52); - expect(out.freshnessStr).toContain('stale 52s'); + expect(out.staleSec).toBe(sec); + expect(out.freshnessStr).toContain(`stale ${sec}s`); }); it('renders real (stale) PnL so the operator can still see last-known numbers', () => { const now = 1_700_000_000_000; const out = computeStatusFreshness({ dataLoading: false, - lastTickMs: now - 30_000, + lastTickMs: now - (TRADE_STALE_AFTER_MS + 5_000), // clearly past the stale threshold nowMs: now, realizedPnl: 7.0, realizedPnlPct: 1.1, diff --git a/apps/web/src/app/api/auth/signup/route.ts b/apps/web/src/app/api/auth/signup/route.ts index 3aafc39..88dc75a 100644 --- a/apps/web/src/app/api/auth/signup/route.ts +++ b/apps/web/src/app/api/auth/signup/route.ts @@ -27,6 +27,8 @@ export async function POST(req: NextRequest) { ); } + if (!data.user) return Response.json({ error: 'no user returned' }, { status: 500 }); + const payload = { user: { id: data.user.id, email: data.user.email }, session: data.session && { diff --git a/packages/core/src/runtime-cache.test.ts b/packages/core/src/runtime-cache.test.ts index db71231..c114812 100644 --- a/packages/core/src/runtime-cache.test.ts +++ b/packages/core/src/runtime-cache.test.ts @@ -30,9 +30,11 @@ describe('stripLiveSourceState', () => { }, ], }, - // Heavy/noisy tick data should still be stripped from DB fallback. + // Heavy/noisy tick data (prices, rawLog) is stripped from the DB fallback. prices: [{ exchange: 'kraken', pair: 'BTC-USD', bid: 101, ask: 102 }], rawLog: [{ at: 'now', text: 'noise' }], + // tradeState is intentionally KEPT: closedTrades is persistent history the + // UI needs even when the runtime cache has expired. tradeState: { closedTrades: [{ pair: 'BTC-USD' }] }, }; @@ -42,6 +44,6 @@ describe('stripLiveSourceState', () => { expect(stripped.tradeStatus).toEqual(payload.tradeStatus); expect(stripped.prices).toBeUndefined(); expect(stripped.rawLog).toBeUndefined(); - expect(stripped.tradeState).toBeUndefined(); + expect(stripped.tradeState).toEqual(payload.tradeState); }); }); diff --git a/packages/core/src/runtime-cache.ts b/packages/core/src/runtime-cache.ts index 0d35113..dbbf9db 100644 --- a/packages/core/src/runtime-cache.ts +++ b/packages/core/src/runtime-cache.ts @@ -17,6 +17,7 @@ const LIVE_SOURCE_STATE_FIELDS = new Set([ 'activityLog', 'openOrders', 'opportunities', + 'prices', 'rawLog', 'recentTrades', 'signals', diff --git a/packages/source-dealdash/src/strategy/decide.test.ts b/packages/source-dealdash/src/strategy/decide.test.ts index fe9841c..4464e18 100644 --- a/packages/source-dealdash/src/strategy/decide.test.ts +++ b/packages/source-dealdash/src/strategy/decide.test.ts @@ -54,9 +54,9 @@ function ctxBase(over: Partial): DecisionContext { } const book = (r: { decisions: Decision[] }, id: number) => - r.decisions.find(d => d.kind === 'book' && d.auctionId === id); + r.decisions.find((d): d is Extract => d.kind === 'book' && d.auctionId === id); const cancel = (r: { decisions: Decision[] }, id: number) => - r.decisions.find(d => d.kind === 'cancel' && d.auctionId === id); + r.decisions.find((d): d is Extract => d.kind === 'cancel' && d.auctionId === id); // ---------- balance state transitions ----------