From cf91c04e909337b0dc0631b39d3b883bf357c859 Mon Sep 17 00:00:00 2001 From: Kev Date: Sun, 12 Jul 2026 23:41:13 -0400 Subject: [PATCH] DS1 follow-up: close the sb-token trust-bug class across all surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OAuth-only 'sb-token' localStorage key was read by profile, slip, dashboard (recent-scans), settings, and tracker for their authenticated fetches. Email/password users never had that key, so those fetches sent no Authorization header and silently returned nothing. - web/src/lib/authToken.js — currentAccessToken() reads the REAL Supabase session (sb--auth-token, v2 top-level or v1 currentSession), legacy fallback. CommonJS so Jest can unit-test it (5 tests). - Swept all 5 pages to the helper (scan already session-first from DS1). - lib/api.ts (0 callers) + ParlayTray (unmounted) left as dead code. 236 suites / 2842 tests green, next build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/unit/authToken.test.js | 39 ++++++++++++++++++++++++++++++++ web/src/app/dashboard/page.tsx | 3 ++- web/src/app/profile/page.tsx | 5 +++-- web/src/app/settings/page.tsx | 3 ++- web/src/app/slip/page.tsx | 5 +++-- web/src/app/tracker/page.tsx | 3 ++- web/src/lib/authToken.js | 41 ++++++++++++++++++++++++++++++++++ 7 files changed, 92 insertions(+), 7 deletions(-) create mode 100644 tests/unit/authToken.test.js create mode 100644 web/src/lib/authToken.js diff --git a/tests/unit/authToken.test.js b/tests/unit/authToken.test.js new file mode 100644 index 0000000..906c647 --- /dev/null +++ b/tests/unit/authToken.test.js @@ -0,0 +1,39 @@ +// DS1 follow-up — currentAccessToken reads the REAL Supabase session, so +// email/password users' authenticated fetches carry a token (the OAuth-only +// 'sb-token' key left them silently unauthenticated everywhere). + +const { currentAccessToken } = require('../../web/src/lib/authToken'); + +function fakeWindow(store) { + const keys = Object.keys(store); + global.window = { + localStorage: { + length: keys.length, + key: (i) => keys[i] ?? null, + getItem: (k) => (k in store ? store[k] : null), + }, + }; +} +afterEach(() => { delete global.window; }); + +describe('currentAccessToken', () => { + test('reads the v2 supabase session (access_token at top level)', () => { + fakeWindow({ 'sb-zmdnczhtdxcddsxzttub-auth-token': JSON.stringify({ access_token: 'TOKEN_V2', refresh_token: 'r' }) }); + expect(currentAccessToken()).toBe('TOKEN_V2'); + }); + test('reads the v1 shape (currentSession.access_token)', () => { + fakeWindow({ 'sb-abc-auth-token': JSON.stringify({ currentSession: { access_token: 'TOKEN_V1' } }) }); + expect(currentAccessToken()).toBe('TOKEN_V1'); + }); + test('falls back to the legacy sb-token key', () => { + fakeWindow({ 'sb-token': 'LEGACY' }); + expect(currentAccessToken()).toBe('LEGACY'); + }); + test('no session → null (never throws on malformed JSON)', () => { + fakeWindow({ 'sb-x-auth-token': 'not json{' }); + expect(currentAccessToken()).toBeNull(); + }); + test('SSR (no window) → null', () => { + expect(currentAccessToken()).toBeNull(); + }); +}); diff --git a/web/src/app/dashboard/page.tsx b/web/src/app/dashboard/page.tsx index c278038..76c4460 100644 --- a/web/src/app/dashboard/page.tsx +++ b/web/src/app/dashboard/page.tsx @@ -15,6 +15,7 @@ import { AccuracyBadge, Skeleton, SkeletonList } from '@/components/vyndr'; import { emptyStateCopy } from '@/lib/emptyState'; // Session 59 (work-order 2.3) — the real pipeline schedule for waiting states. import { nextRunLabelET } from '@/lib/pipelineSchedule'; +import { currentAccessToken } from '@/lib/authToken'; type Sport = 'NBA' | 'MLB' | 'WNBA'; @@ -170,7 +171,7 @@ export default function DashboardPage() { .catch(() => setMostParlayed([])); if (user) { - const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null; + const token = currentAccessToken(); fetch('/api/user/recent-scans', { headers: token ? { Authorization: `Bearer ${token}` } : {}, }) diff --git a/web/src/app/profile/page.tsx b/web/src/app/profile/page.tsx index ec15efa..2ec8da8 100644 --- a/web/src/app/profile/page.tsx +++ b/web/src/app/profile/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/contexts/AuthContext'; +import { currentAccessToken } from '@/lib/authToken'; interface FullProfile { id: string; @@ -29,7 +30,7 @@ export default function ProfilePage() { useEffect(() => { if (!user) return; - const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null; + const token = currentAccessToken(); fetch('/api/user/profile', { headers: token ? { Authorization: `Bearer ${token}` } : {}, }) @@ -42,7 +43,7 @@ export default function ProfilePage() { if (!confirm('Cancel your subscription at the end of the current period?')) return; setWorking(true); setError(''); - const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null; + const token = currentAccessToken(); const res = await fetch('/api/user/profile', { method: 'PUT', headers: { diff --git a/web/src/app/settings/page.tsx b/web/src/app/settings/page.tsx index ec17d39..d192982 100644 --- a/web/src/app/settings/page.tsx +++ b/web/src/app/settings/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/contexts/AuthContext'; +import { currentAccessToken } from '@/lib/authToken'; /** * /settings (Session 42 — Player Intelligence design). @@ -173,7 +174,7 @@ export default function SettingsPage() { setDeleting(true); setDelError(''); try { - const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null; + const token = currentAccessToken(); const res = await fetch('/api/user/profile', { method: 'DELETE', headers: token ? { Authorization: `Bearer ${token}` } : {}, diff --git a/web/src/app/slip/page.tsx b/web/src/app/slip/page.tsx index 62ef22f..846a49a 100644 --- a/web/src/app/slip/page.tsx +++ b/web/src/app/slip/page.tsx @@ -17,6 +17,7 @@ import SectionHead from '@/components/vyndr/SectionHead'; import VBtn from '@/components/vyndr/VBtn'; import GradeBadge from '@/components/vyndr/GradeBadge'; import { useParlay } from '@/contexts/ParlayContext'; +import { currentAccessToken } from '@/lib/authToken'; type Sport = 'MLB' | 'NBA' | 'WNBA'; @@ -109,7 +110,7 @@ export default function SlipPage() { setGraded(false); setAdded(false); try { - const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null; + const token = currentAccessToken(); const res = await fetch('/api/slips/parse', { method: 'POST', headers: { @@ -185,7 +186,7 @@ export default function SlipPage() { if (!legs || grading) return; setGrading(true); setGrades(legs.map(() => ({ status: 'pending' }))); - const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null; + const token = currentAccessToken(); const results: LegGrade[] = []; for (const leg of legs) { if (!legReady(leg)) { diff --git a/web/src/app/tracker/page.tsx b/web/src/app/tracker/page.tsx index cb8ab86..0bca2f9 100644 --- a/web/src/app/tracker/page.tsx +++ b/web/src/app/tracker/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/contexts/AuthContext'; +import { currentAccessToken } from '@/lib/authToken'; type Period = 'weekly' | 'monthly' | 'all_time'; @@ -28,7 +29,7 @@ interface Bet { const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'; function getAuthHeaders(): Record { - const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null; + const token = currentAccessToken(); return token ? { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } : { 'Content-Type': 'application/json' }; diff --git a/web/src/lib/authToken.js b/web/src/lib/authToken.js new file mode 100644 index 0000000..19237c8 --- /dev/null +++ b/web/src/lib/authToken.js @@ -0,0 +1,41 @@ +/** + * currentAccessToken (DS1 follow-up) — the bearer token for authenticated + * browser fetches, robust across EVERY sign-in method. + * + * The bug this closes: surfaces read `localStorage.getItem('sb-token')`, but + * that key is written ONLY by the OAuth callback. Email/password users (the + * majority) had no `sb-token`, so their authenticated fetches (profile stats, + * tracker, slip, settings) sent no Authorization header and silently returned + * nothing. DS1 fixed the scan→ledger path; this helper fixes the rest. + * + * Supabase-js persists the session as JSON under `sb--auth-token` + * with `access_token` at the top level (v2) or under `currentSession` (v1). + * We read that directly (best-effort, synchronous), falling back to the legacy + * key. Prefer `useAuth().session?.access_token` in components that already have + * it; this helper is for the ones that don't. + * + * CommonJS (like colorContract.js / clvDisplay.js) so the plain-JS Jest suite + * can require it AND the .tsx app can import it (Next allowJs). + * + * @returns {string|null} + */ +function currentAccessToken() { + if (typeof window === 'undefined') return null; + try { + for (let i = 0; i < window.localStorage.length; i += 1) { + const k = window.localStorage.key(i) || ''; + if (/^sb-.*-auth-token$/.test(k)) { + const raw = window.localStorage.getItem(k); + if (!raw) continue; + const v = JSON.parse(raw); + const tok = (v && v.access_token) || (v && v.currentSession && v.currentSession.access_token); + if (tok) return tok; + } + } + } catch { + /* fall through to the legacy key */ + } + return window.localStorage.getItem('sb-token'); +} + +module.exports = { currentAccessToken };