DS1 follow-up: close the sb-token trust-bug class across all surfaces

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-<ref>-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) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-12 23:41:13 -04:00
parent bc5285d9e7
commit cf91c04e90
7 changed files with 92 additions and 7 deletions
+39
View File
@@ -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();
});
});
+2 -1
View File
@@ -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}` } : {},
})
+3 -2
View File
@@ -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: {
+2 -1
View File
@@ -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}` } : {},
+3 -2
View File
@@ -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)) {
+2 -1
View File
@@ -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<string, string> {
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' };
+41
View File
@@ -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-<project-ref>-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 };