From 18300c9ef6dd0c980553658d2cc546b89f2233d8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 10:47:28 +0000 Subject: [PATCH] Stop treating an Auth outage as a successful local save. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-in writes asked the Auth server who the user was. When that call failed, the app stored the source only in the browser and showed success. The id already in the session is enough, and a missing user now fails the save instead of hiding it. Co-authored-by: Gáborjáni-Szabó Ádám --- src/lib/stores/debate-context-store.ts | 4 +- src/lib/stores/debate-store.ts | 12 +-- src/lib/stores/session-store.ts | 118 +++++++++------------- src/lib/stores/settings-store.ts | 4 +- src/lib/stores/sparring-store.ts | 9 +- src/lib/supabase/current-user.ts | 40 ++++++++ src/locales/en.json | 1 + src/locales/es.json | 1 + src/locales/hu.json | 1 + src/pages/LibraryPage.tsx | 15 ++- src/test/stores/session-save-auth.test.ts | 87 ++++++++++++++++ 11 files changed, 206 insertions(+), 86 deletions(-) create mode 100644 src/lib/supabase/current-user.ts create mode 100644 src/test/stores/session-save-auth.test.ts diff --git a/src/lib/stores/debate-context-store.ts b/src/lib/stores/debate-context-store.ts index ea743827d..874216127 100644 --- a/src/lib/stores/debate-context-store.ts +++ b/src/lib/stores/debate-context-store.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; import { supabase } from '@/lib/supabase/client'; +import { currentUserId } from '@/lib/supabase/current-user'; import type { DebateContextFeature, DebateContextOutputKind, @@ -118,8 +119,7 @@ export const useDebateContextStore = create()((set, get) => set((state) => ({ pins: { ...state.pins, [debateId]: withPin(state.pins[debateId] ?? [], ref, !pinned) } })); const save = async (): Promise<{ error: { message?: string; code?: string } | null }> => { if (generation !== pinGeneration) return { error: null }; - const { data } = await supabase.auth.getUser(); - const userId = data.user?.id; + const userId = await currentUserId(); if (generation !== pinGeneration) return { error: null }; if (!userId) return { error: { message: 'Sign in to pin results' } }; return pinned diff --git a/src/lib/stores/debate-store.ts b/src/lib/stores/debate-store.ts index 271d88dbe..c7fd3bd42 100644 --- a/src/lib/stores/debate-store.ts +++ b/src/lib/stores/debate-store.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; import { supabase } from '@/lib/supabase/client'; +import { currentUserId } from '@/lib/supabase/current-user'; /** A favor, En contra, both sides, or not side-specific. */ export type DebateSide = 'pro' | 'con' | 'both' | 'neutral'; @@ -93,8 +94,7 @@ function sortDebates(debates: Debate[]): Debate[] { } async function requireUserId(): Promise { - const { data } = await supabase.auth.getUser(); - const userId = data.user?.id; + const userId = await currentUserId(); if (!userId) throw new Error('Sign in to manage debates'); return userId; } @@ -124,10 +124,10 @@ export const useDebateStore = create()( hydrate: async () => { const epoch = hydrationEpoch; - const { data: { user } } = await supabase.auth.getUser(); - if (epoch !== hydrationEpoch || !user) return; - if (get().ownerUserId !== user.id) { - set({ ownerUserId: user.id, debates: [], currentDebateId: null, oneOff: false, hydrated: false }); + const userId = await currentUserId(); + if (epoch !== hydrationEpoch || !userId) return; + if (get().ownerUserId !== userId) { + set({ ownerUserId: userId, debates: [], currentDebateId: null, oneOff: false, hydrated: false }); } const writesBefore = debateWrites; const [debatesResult, currentResult] = await Promise.all([ diff --git a/src/lib/stores/session-store.ts b/src/lib/stores/session-store.ts index dcdea8e78..d5cd92229 100644 --- a/src/lib/stores/session-store.ts +++ b/src/lib/stores/session-store.ts @@ -7,6 +7,7 @@ import type { BrainstormOutput, BrainstormInput } from '../ai/schemas/brainstorm import type { FactoidsOutput, FactoidsInput } from '../ai/schemas/factoids'; import type { AskOutput } from '../ai/schemas/ask'; import { supabase } from '@/lib/supabase/client'; +import { currentUserId } from '@/lib/supabase/current-user'; import i18n from '@/i18n'; import { activeDebateId, useDebateStore, type DebateSide } from './debate-store'; @@ -288,8 +289,11 @@ function mergeSourceFolders(currentFolders: string[], sources: Source[] = []) { } async function getUserId() { - const { data } = await supabase.auth.getUser(); - return data.user?.id || null; + return await currentUserId(); +} + +function signInRequired(action: string): Error { + return new Error(`Sign in to ${action}`); } type EqQuery = { @@ -509,13 +513,7 @@ export const useSessionStore = create()( const place = (list: Source[], item: Source) => restoredId ? [...list, item].sort((x, y) => y.createdAt - x.createdAt) : [item, ...list]; const userId = await getUserId(); - if (!userId) { - set((state) => ({ - sourceFolders: mergeSourceFolders(state.sourceFolders, [localItem]), - sourceLibrary: place(state.sourceLibrary, localItem), - })); - return; - } + if (!userId) throw signInRequired('save this source'); const folderResult = await supabase .from('source_folders') @@ -560,14 +558,17 @@ export const useSessionStore = create()( }, createSourceFolder: async (folder) => { const normalized = normalizeFolder(folder); + const userId = await getUserId(); + if (!userId) throw signInRequired('save this folder'); + throwOnWriteError( + await supabase + .from('source_folders') + .upsert({ user_id: userId, name: normalized }, { onConflict: 'user_id,name' }), + 'save source folder', + ); set((state) => ({ sourceFolders: mergeSourceFolders([...state.sourceFolders, normalized], state.sourceLibrary), })); - const userId = await getUserId(); - if (!userId) return; - await supabase - .from('source_folders') - .upsert({ user_id: userId, name: normalized }, { onConflict: 'user_id,name' }); }, updateSource: async (id, patch) => { const current = get().sourceLibrary.find((s) => s.id === id); @@ -595,7 +596,7 @@ export const useSessionStore = create()( try { const userId = await getUserId(); - if (!userId) return; + if (!userId) throw signInRequired('save this source'); if (nextSource.folder) { const folderResult = await supabase .from('source_folders') @@ -660,7 +661,7 @@ export const useSessionStore = create()( set((state) => ({ sourceLibrary: state.sourceLibrary.map((source) => updates.get(source.id) ?? source) })); try { const userId = await getUserId(); - if (!userId) return; + if (!userId) throw signInRequired('save this source'); await Promise.all(Array.from(updates.values()).map(async (source) => { const result = await applyUserScopedIdFilter( supabase.from('sources').update({ metadata: source.metadata ?? null }), @@ -696,7 +697,7 @@ export const useSessionStore = create()( }; const userId = await getUserId(); - if (!userId) return applyRename(); + if (!userId) throw signInRequired('rename this folder'); const folderResult = await supabase .from('source_folders') .upsert({ user_id: userId, name: to }, { onConflict: 'user_id,name' }); @@ -737,7 +738,7 @@ export const useSessionStore = create()( }; const userId = await getUserId(); - if (!userId) return applyDelete(); + if (!userId) throw signInRequired('delete this folder'); if (affected.length > 0) { throwOnWriteError( await supabase.from('sources').update({ folder: 'General' }).eq('user_id', userId).eq('debate_id', debateId).eq('folder', folder), @@ -757,13 +758,12 @@ export const useSessionStore = create()( const debateId = useDebateStore.getState().currentDebateId; if (!debateId) throw new DebateRequiredError(); const userId = await getUserId(); - if (userId) { - throwOnWriteError( - await supabase.from('sources').update({ is_active: isActive }) - .eq('user_id', userId).eq('debate_id', debateId).eq('folder', normalized), - 'update source folder activity', - ); - } + if (!userId) throw signInRequired('update this folder'); + throwOnWriteError( + await supabase.from('sources').update({ is_active: isActive }) + .eq('user_id', userId).eq('debate_id', debateId).eq('folder', normalized), + 'update source folder activity', + ); set((state) => ({ sourceLibrary: state.sourceLibrary.map((source) => source.debateId === debateId && normalizeFolder(source.folder) === normalized ? { ...source, isActive } : source @@ -772,10 +772,9 @@ export const useSessionStore = create()( }, removeSource: async (id) => { const userId = await getUserId(); - if (userId) { - const result = await applyUserScopedIdFilter(supabase.from('sources').delete(), userId, id); - throwOnWriteError(result, 'delete source'); - } + if (!userId) throw signInRequired('delete this source'); + const result = await applyUserScopedIdFilter(supabase.from('sources').delete(), userId, id); + throwOnWriteError(result, 'delete source'); set((state) => ({ sourceLibrary: state.sourceLibrary.filter((source) => source.id !== id) })); }, toggleSource: async (id) => { @@ -783,12 +782,11 @@ export const useSessionStore = create()( if (!current) return; const nextValue = !current.isActive; const userId = await getUserId(); - if (userId) { - throwOnWriteError( - await applyUserScopedIdFilter(supabase.from('sources').update({ is_active: nextValue }), userId, id), - 'toggle source activity', - ); - } + if (!userId) throw signInRequired('update this source'); + throwOnWriteError( + await applyUserScopedIdFilter(supabase.from('sources').update({ is_active: nextValue }), userId, id), + 'toggle source activity', + ); set((state) => ({ sourceLibrary: state.sourceLibrary.map((source) => source.id === id ? { ...source, isActive: nextValue } : source), })); @@ -822,12 +820,8 @@ export const useSessionStore = create()( saveCurrentCase: async (title) => { const current = get().generateResult; if (!current) return; - const localItem = { id: generateId(), data: current, createdAt: Date.now(), title }; const userId = await getUserId(); - if (!userId) { - set((state) => ({ savedCases: [localItem, ...state.savedCases] })); - return; - } + if (!userId) throw signInRequired('save this case'); const { data, error } = await supabase .from('cases') .insert({ user_id: userId, title, input: {}, output: current }) @@ -839,9 +833,13 @@ export const useSessionStore = create()( set((state) => ({ savedCases: [savedItem, ...state.savedCases] })); }, deleteSavedCase: async (id) => { - set((state) => ({ savedCases: state.savedCases.filter((c) => c.id !== id) })); const userId = await getUserId(); - await applyUserScopedIdFilter(supabase.from('cases').delete(), userId, id); + if (!userId) throw signInRequired('delete this case'); + throwOnWriteError( + await applyUserScopedIdFilter(supabase.from('cases').delete(), userId, id), + 'delete case', + ); + set((state) => ({ savedCases: state.savedCases.filter((c) => c.id !== id) })); }, loadSavedCase: (id) => { const item = get().savedCases.find((c) => c.id === id); @@ -859,12 +857,8 @@ export const useSessionStore = create()( saveCurrentAttack: async (title) => { const current = get().attackResult; if (!current) return; - const localItem = { id: generateId(), data: current, createdAt: Date.now(), title }; const userId = await getUserId(); - if (!userId) { - set((state) => ({ savedAttacks: [localItem, ...state.savedAttacks] })); - return; - } + if (!userId) throw signInRequired('save this attack'); const { data, error } = await supabase .from('attacks') .insert({ user_id: userId, title, input: {}, output: current }) @@ -891,12 +885,8 @@ export const useSessionStore = create()( saveCurrentBrainstorm: async (title) => { const current = get().brainstormResult; if (!current) return; - const localItem = { id: generateId(), data: current, createdAt: Date.now(), title }; const userId = await getUserId(); - if (!userId) { - set((state) => ({ savedBrainstorms: [localItem, ...state.savedBrainstorms] })); - return; - } + if (!userId) throw signInRequired('save this brainstorm'); const { data, error } = await supabase .from('brainstorms') .insert({ user_id: userId, title, input: {}, output: current }) @@ -923,12 +913,8 @@ export const useSessionStore = create()( saveCurrentFactoids: async (title) => { const current = get().factoidsResult; if (!current) return; - const localItem = { id: generateId(), data: current, createdAt: Date.now(), title }; const userId = await getUserId(); - if (!userId) { - set((state) => ({ savedFactoids: [localItem, ...state.savedFactoids] })); - return; - } + if (!userId) throw signInRequired('save these factoids'); const { data, error } = await supabase .from('factoids') .insert({ user_id: userId, title, input: {}, output: current }) @@ -961,12 +947,8 @@ export const useSessionStore = create()( saveCurrentChat: async (title) => { const current = get().askMessages; if (current.length === 0) return; - const localItem = { id: generateId(), data: current, createdAt: Date.now(), title }; const userId = await getUserId(); - if (!userId) { - set((state) => ({ savedChats: [localItem, ...state.savedChats] })); - return; - } + if (!userId) throw signInRequired('save this chat'); const { data: chat, error } = await supabase .from('chats') .insert({ user_id: userId, title }) @@ -995,12 +977,8 @@ export const useSessionStore = create()( }, saveFlowchart: async (title, data, debateId = activeDebateId()) => { - const localItem = { id: generateId(), data, createdAt: Date.now(), title, debateId, side: 'neutral' as const }; const userId = await getUserId(); - if (!userId) { - set((state) => ({ savedFlowcharts: [localItem, ...state.savedFlowcharts] })); - return; - } + if (!userId) throw signInRequired('save this flowchart'); const { data: saved, error } = await supabase .from('flowcharts') .insert({ @@ -1029,7 +1007,7 @@ export const useSessionStore = create()( recordResult: async (kind, { title, input, output, debateId, side }) => { const userId = await getUserId(); - if (!userId) throw new Error('[SessionStore] Sign in to save results to a debate'); + if (!userId) throw signInRequired('save results to a debate'); const { data, error } = await supabase .from(RESULT_TABLES[kind]) .insert({ user_id: userId, debate_id: debateId, side, title: title.slice(0, 300) || 'Untitled', input: input ?? {}, output }) @@ -1046,7 +1024,7 @@ export const useSessionStore = create()( updateRecordedResult: async (kind, id, output) => { const userId = await getUserId(); - if (!userId) return; + if (!userId) throw signInRequired('update this result'); throwOnWriteError( await applyUserScopedIdFilter(supabase.from(RESULT_TABLES[kind]).update({ output }), userId, id), `update ${kind}`, @@ -1325,7 +1303,7 @@ async function syncAskChatNow(debateId: string | null): Promise { if (askMessages.length === 0 || askPersistedCount >= askMessages.length) return; if (!askChatId && !debateId) return; const userId = await getUserId(); - if (!userId) return; + if (!userId) throw signInRequired('save this chat'); // Clearing or replacing the conversation while a write is in flight must not // hand this chat or its count to the next conversation. const sameConversation = () => { diff --git a/src/lib/stores/settings-store.ts b/src/lib/stores/settings-store.ts index 4d96bd91d..4033ee2c1 100644 --- a/src/lib/stores/settings-store.ts +++ b/src/lib/stores/settings-store.ts @@ -14,6 +14,7 @@ import { } from '../../../supabase/functions/_shared/model-catalog'; import type { ProviderConfig } from '../ai/providers/types'; import { supabase } from '@/lib/supabase/client'; +import { currentUserId } from '@/lib/supabase/current-user'; export type FeatureTab = 'generate' | 'attack' | 'brainstorm' | 'factoids' | 'ask' | 'sparring'; type CoreFeatureTab = Exclude; @@ -160,8 +161,7 @@ async function canAccessProModels(settings: Record): Promise()( hydrateSavedSessions: async () => { if (get().hydrated) return; const epoch = hydrationEpoch; - const { data: { user } } = await supabase.auth.getUser(); + const userId = await currentUserId(); if (epoch !== hydrationEpoch) return; - if (!user) { + if (!userId) { set({ hydrated: true }); return; } @@ -505,8 +506,8 @@ export const useSparringStore = create()( deleteSavedSession: async (id) => { const previous = get().savedSessions; set({ savedSessions: previous.filter((session) => session.id !== id) }); - const { data: { user } } = await supabase.auth.getUser(); - if (!user) return; + const userId = await currentUserId(); + if (!userId) return; const { error } = await supabase.from('sparring_sessions').delete().eq('id', id); if (error) { set({ savedSessions: previous }); diff --git a/src/lib/supabase/current-user.ts b/src/lib/supabase/current-user.ts new file mode 100644 index 000000000..19897d1e6 --- /dev/null +++ b/src/lib/supabase/current-user.ts @@ -0,0 +1,40 @@ +import { getActiveUserId, supabase } from './client'; + +/** + * Who is signed in, without treating an Auth-server failure as signed out. + * + * `auth.getUser()` asks the server. When that call fails, the old stores saved + * only in this browser and told the student the save worked. The id AuthProvider + * already has, then the local session, still name the user so the write can go + * to the database (or fail in the open). + */ +function rememberedUserId(): string | null { + // Some unit tests replace the client module and omit this export. Reading a + // missing mock export throws; that is the same as "nothing remembered". + try { + if (typeof getActiveUserId !== 'function') return null; + return getActiveUserId(); + } catch { + return null; + } +} + +export async function currentUserId(): Promise { + const active = rememberedUserId(); + if (active) return active; + + const auth = supabase.auth; + if (typeof auth?.getUser === 'function') { + try { + const { data, error } = await auth.getUser(); + if (!error && data.user?.id) return data.user.id; + } catch { + // The Auth server did not answer. Fall through to the local session. + } + } + + if (typeof auth?.getSession !== 'function') return null; + const { data, error } = await auth.getSession(); + if (error) return null; + return data.session?.user?.id ?? null; +} diff --git a/src/locales/en.json b/src/locales/en.json index 7a2b4ac96..9243559a7 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1601,6 +1601,7 @@ "newFolderPlaceholder": "Folder name...", "newFolderHint": "Need a new folder? Use the \"New folder\" button up top.", "createFolder": "Create folder", + "saveFailed": "Could not save this source.", "sortBy": "Sort by", "sortSmart": "Smart", "sortMostUsed": "Most used", diff --git a/src/locales/es.json b/src/locales/es.json index e03254f8e..cc3e613de 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1599,6 +1599,7 @@ "newFolderPlaceholder": "Nombre de carpeta...", "newFolderHint": "¿Necesitas una carpeta nueva? Usa el botón \"Nueva carpeta\" arriba.", "createFolder": "Crear carpeta", + "saveFailed": "No se pudo guardar esta fuente.", "sortBy": "Ordenar por", "sortSmart": "Inteligente", "sortMostUsed": "Más usadas", diff --git a/src/locales/hu.json b/src/locales/hu.json index 46a0e4953..9f3486986 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -1599,6 +1599,7 @@ "newFolderPlaceholder": "Mappa neve...", "newFolderHint": "Új mappára van szükség? Használd a felső „Új mappa” gombot.", "createFolder": "Mappa létrehozása", + "saveFailed": "Ezt a forrást nem sikerült menteni.", "sortBy": "Rendezés", "sortSmart": "Intelligens", "sortMostUsed": "Legtöbbet használt", diff --git a/src/pages/LibraryPage.tsx b/src/pages/LibraryPage.tsx index 9853279c4..27357364d 100644 --- a/src/pages/LibraryPage.tsx +++ b/src/pages/LibraryPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useSessionStore, type Source } from '@/lib/stores/session-store'; +import { DebateRequiredError, useSessionStore, type Source } from '@/lib/stores/session-store'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button, buttonVariants } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -243,7 +243,18 @@ export default function LibraryPage() { if (errors.title) return addTitleRef.current?.focus(); if (errors.content) return addContentRef.current?.focus(); // The Library always shows the Current Debate, even while tools run one-off. - await addSource({ ...newSource, debateId: currentDebate?.id ?? null }); + try { + await addSource({ ...newSource, debateId: currentDebate?.id ?? null }); + } catch (error) { + toast({ + title: t('libraryPage.saveFailed', 'Could not save this source.'), + description: error instanceof DebateRequiredError + ? t('debate.sourceNeedsDebate', 'Start or select a debate before saving sources.') + : undefined, + variant: 'destructive', + }); + return; + } setNewSource({ title: '', content: '', type: 'text', origin: '', folder: newSource.folder }); setIsAdding(false); }; diff --git a/src/test/stores/session-save-auth.test.ts b/src/test/stores/session-save-auth.test.ts new file mode 100644 index 000000000..fd53ab74d --- /dev/null +++ b/src/test/stores/session-save-auth.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { setActiveAccessToken, supabase } from '@/lib/supabase/client'; +import { currentUserId } from '@/lib/supabase/current-user'; +import { useSessionStore } from '@/lib/stores/session-store'; +import { resetDebates, seedCurrentDebate, TEST_DEBATE_ID } from '@/test/helpers/debate'; + +const DEBATE_ID = TEST_DEBATE_ID; + +function sourceRow() { + return { + id: '22222222-2222-4222-8222-222222222222', + title: 'School wifi notes', + content: 'The packet was lost.', + type: 'text', + origin: null as string | null, + folder: 'General', + metadata: null as null, + debate_id: DEBATE_ID, + is_active: true, + created_at: '2026-10-22T00:00:00.000Z', + }; +} + +describe('saves when the Auth server is down', () => { + afterEach(() => { + vi.restoreAllMocks(); + setActiveAccessToken(null, null); + resetDebates(); + useSessionStore.setState({ sourceLibrary: [], sourceFolders: ['General'], savedCases: [] }); + }); + + it('uses the local session when getUser fails', async () => { + setActiveAccessToken(null, null); + vi.spyOn(supabase.auth, 'getUser').mockResolvedValue({ + data: { user: null }, + error: { message: 'Auth unavailable', name: 'AuthRetryableFetchError', status: 503 }, + } as never); + vi.spyOn(supabase.auth, 'getSession').mockResolvedValue({ + data: { session: { user: { id: 'user-1' } } }, + error: null, + } as never); + + await expect(currentUserId()).resolves.toBe('user-1'); + expect(supabase.auth.getUser).toHaveBeenCalled(); + }); + + it('writes a source to the database instead of pretending a local copy was saved', async () => { + setActiveAccessToken('token', 'user-1'); + seedCurrentDebate(); + const getUser = vi.spyOn(supabase.auth, 'getUser').mockRejectedValue(new Error('503')); + const insert = vi.fn(() => ({ + select: () => ({ + single: async (): Promise<{ data: ReturnType; error: null }> => ({ data: sourceRow(), error: null }), + }), + })); + vi.spyOn(supabase, 'from').mockImplementation((table: string) => { + if (table === 'source_folders') { + return { upsert: async (): Promise<{ error: null }> => ({ error: null }) } as never; + } + if (table === 'sources') return { insert } as never; + throw new Error(`unexpected table ${table}`); + }); + + await useSessionStore.getState().addSource({ title: 'School wifi notes', content: 'The packet was lost.', type: 'text' }); + + expect(getUser).not.toHaveBeenCalled(); + expect(insert).toHaveBeenCalledOnce(); + expect(useSessionStore.getState().sourceLibrary.map((source) => source.id)).toEqual([sourceRow().id]); + }); + + it('reports failure instead of keeping a source that only exists in the browser', async () => { + setActiveAccessToken(null, null); + seedCurrentDebate(); + vi.spyOn(supabase.auth, 'getUser').mockResolvedValue({ data: { user: null }, error: null } as never); + vi.spyOn(supabase.auth, 'getSession').mockResolvedValue({ data: { session: null }, error: null } as never); + const from = vi.spyOn(supabase, 'from'); + + await expect(useSessionStore.getState().addSource({ + title: 'School wifi notes', + content: 'The packet was lost.', + type: 'text', + })).rejects.toThrow(/Sign in to save this source/); + + expect(from).not.toHaveBeenCalled(); + expect(useSessionStore.getState().sourceLibrary).toEqual([]); + }); +});