From a891d40aec20cf393415c50f2aaed2c1b84a3d45 Mon Sep 17 00:00:00 2001
From: Adam
Date: Thu, 22 Oct 2026 11:31:07 +0000
Subject: [PATCH 01/12] Sign out a revoked session and fix the email code check
(H1, M4)
H1: when Supabase refuses a signed-in request (401 from REST or Functions,
401/403 from Auth's user lookup), force a token refresh outside the request.
If Auth refuses that too, sign this browser out locally and show
"Your session expired. Sign in again." on /login. Outages, 5xx and rate
limits keep the session.
M4: the code field keeps digits only, Verify waits for six digits, and a
refused code says it may be wrong or expired.
Co-Authored-By: Claude Opus 5.5
---
e2e/helpers/auth.ts | 10 +++
src/lib/supabase/client.ts | 43 +++++++++-
src/lib/supabase/session-expiry.ts | 84 ++++++++++++++++++
src/locales/en.json | 3 +-
src/locales/es.json | 3 +-
src/locales/hu.json | 3 +-
src/pages/LoginPage.tsx | 23 ++++-
src/test/auth/login-otp.test.tsx | 59 +++++++++++++
src/test/auth/session-expiry.test.ts | 122 +++++++++++++++++++++++++++
9 files changed, 341 insertions(+), 9 deletions(-)
create mode 100644 src/lib/supabase/session-expiry.ts
create mode 100644 src/test/auth/login-otp.test.tsx
create mode 100644 src/test/auth/session-expiry.test.ts
diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts
index 4a6e17918..f325f7b3d 100644
--- a/e2e/helpers/auth.ts
+++ b/e2e/helpers/auth.ts
@@ -43,6 +43,16 @@ export async function stubSupabaseSession(page: Page, opts: StubAuthOptions = {}
}
}, { key: `debate-auth-${env}`, value: session });
+ // The bogus JWT makes data requests fail with 401, and the client then
+ // forces a token refresh to tell a revoked session from a stale one. Accept
+ // that refresh with the same stub session, so a render check keeps its
+ // session and never asks the real Auth server about it.
+ await page.route((url) => url.pathname.endsWith('/auth/v1/token') && url.searchParams.get('grant_type') === 'refresh_token', (route) => route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ ...session, expires_at: Math.floor(Date.now() / 1000) + HOUR_SECONDS }),
+ }));
+
// Credential-free render checks must not call the production announcement
// function. Tests that exercise announcements register a more specific
// route after this default.
diff --git a/src/lib/supabase/client.ts b/src/lib/supabase/client.ts
index 1f68f532a..56b32021f 100644
--- a/src/lib/supabase/client.ts
+++ b/src/lib/supabase/client.ts
@@ -5,12 +5,51 @@ import {
FunctionsRelayError,
} from '@supabase/supabase-js';
import { getActiveEnv, SUPABASE_URL, SUPABASE_ANON_KEY } from './env';
+import { verifySessionAfterRejection } from './session-expiry';
export { SUPABASE_URL };
/** Where supabase-js keeps this project's session; each project has its own. */
export const AUTH_STORAGE_KEY = `debate-auth-${getActiveEnv()}`;
+let activeAccessToken: string | null = null;
+let activeUserId: string | null = null;
+
+/**
+ * Whether a Supabase answer says the server refused this browser's session.
+ * A 403 from REST is a policy answer, not an auth one, so only Auth's own
+ * user lookup counts 403. The token and logout endpoints are left out: the
+ * check that follows a rejection calls them itself.
+ */
+export function isAuthRejection(url: string, status: number): boolean {
+ let path: string;
+ try {
+ path = new URL(url, SUPABASE_URL).pathname;
+ } catch {
+ return false;
+ }
+ if (path === '/auth/v1/user') return status === 401 || status === 403;
+ if (path.startsWith('/rest/v1/') || path.startsWith('/functions/v1/')) return status === 401;
+ return false;
+}
+
+function requestUrl(input: RequestInfo | URL): string {
+ if (typeof input === 'string') return input;
+ if (input instanceof URL) return input.href;
+ return input.url;
+}
+
+// A signed-in request the server refused may mean a revoked session. supabase-js
+// holds its session lock while some of these requests run, so the check (which
+// takes that lock) is queued for after the request instead of run inside it.
+const watchedFetch: typeof fetch = async (input, init) => {
+ const response = await globalThis.fetch(input, init);
+ if (activeAccessToken && isAuthRejection(requestUrl(input), response.status)) {
+ setTimeout(() => void verifySessionAfterRejection(supabase.auth), 0);
+ }
+ return response;
+};
+
export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
auth: {
persistSession: true,
@@ -20,11 +59,9 @@ export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
flowType: 'pkce',
storageKey: AUTH_STORAGE_KEY,
},
+ global: { fetch: watchedFetch },
});
-let activeAccessToken: string | null = null;
-let activeUserId: string | null = null;
-
export function setActiveAccessToken(accessToken: string | null, userId: string | null = null): void {
activeAccessToken = accessToken;
activeUserId = userId;
diff --git a/src/lib/supabase/session-expiry.ts b/src/lib/supabase/session-expiry.ts
new file mode 100644
index 000000000..fab29a27e
--- /dev/null
+++ b/src/lib/supabase/session-expiry.ts
@@ -0,0 +1,84 @@
+import { isAuthApiError, isAuthRetryableFetchError, isAuthSessionMissingError } from '@supabase/supabase-js';
+import { getActiveEnv } from './env';
+
+// Beside the session itself (client.ts's AUTH_STORAGE_KEY), per project.
+const EXPIRED_FLAG_KEY = `debate-auth-${getActiveEnv()}-expired`;
+// After the server accepts a refresh, later refusals in the same burst are the
+// old token's requests landing late. Don't refresh again for each of them.
+const RECHECK_AFTER_MS = 10_000;
+
+type SessionAuth = {
+ refreshSession: () => Promise<{ error: unknown }>;
+ signOut: (options: { scope: 'local' }) => Promise;
+};
+
+/**
+ * Whether a failed forced refresh means the session is gone for good (revoked,
+ * deleted account, reset password) rather than Auth being unreachable. Network
+ * failures, 5xx and rate limits keep the session: that is an outage.
+ */
+export function refreshMeansRevoked(error: unknown): boolean {
+ if (!error) return false;
+ if (isAuthRetryableFetchError(error)) return false;
+ if (isAuthSessionMissingError(error)) return true;
+ if (isAuthApiError(error)) return error.status >= 400 && error.status < 500 && error.status !== 429;
+ return false;
+}
+
+let checking = false;
+let confirmedAt = Number.NEGATIVE_INFINITY;
+
+/**
+ * A request was refused for auth. Ask Auth for a new token: if it refuses that
+ * too, the session is dead, so sign this browser out and leave a note for the
+ * login page. Run it outside any supabase-js request (client.ts queues it).
+ */
+export async function verifySessionAfterRejection(auth: SessionAuth, now: () => number = Date.now): Promise<'kept' | 'expired' | 'skipped'> {
+ if (checking || now() - confirmedAt < RECHECK_AFTER_MS) return 'skipped';
+ checking = true;
+ try {
+ const { error } = await auth.refreshSession();
+ if (!error) confirmedAt = now();
+ if (!refreshMeansRevoked(error)) return 'kept';
+ markSessionExpired();
+ // Local scope: the server already dropped this session, and other devices are not ours to sign out.
+ await auth.signOut({ scope: 'local' });
+ return 'expired';
+ } catch (error) {
+ console.warn('[Auth] Session check after a refused request failed:', error);
+ return 'kept';
+ } finally {
+ checking = false;
+ }
+}
+
+/** Test hook: forget the last confirmed refresh. */
+export function resetSessionExpiryCheck(): void {
+ checking = false;
+ confirmedAt = Number.NEGATIVE_INFINITY;
+}
+
+function markSessionExpired(): void {
+ try {
+ sessionStorage.setItem(EXPIRED_FLAG_KEY, '1');
+ } catch {
+ // Storage blocked: the login page just won't say why.
+ }
+}
+
+/** Whether this tab was signed out because the server refused its session. */
+export function sessionExpiredNotice(): boolean {
+ try {
+ return sessionStorage.getItem(EXPIRED_FLAG_KEY) === '1';
+ } catch {
+ return false;
+ }
+}
+
+export function clearSessionExpiredNotice(): void {
+ try {
+ sessionStorage.removeItem(EXPIRED_FLAG_KEY);
+ } catch {
+ // Nothing to clear.
+ }
+}
diff --git a/src/locales/en.json b/src/locales/en.json
index 8ad79672d..9267fdf85 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -545,6 +545,7 @@
"failed": "Could not start sign in",
"tryAgain": "Please try again.",
"backHome": "Back to DebateLab",
+ "sessionExpired": "Your session expired. Sign in again.",
"errors": {
"demoNotFound": "Demo code not found.",
"demoUnusable": "This demo code cannot be used.",
@@ -552,7 +553,7 @@
"demoExpired": "This demo code has expired.",
"demoUsedUp": "This demo code has no uses left.",
"rateLimited": "Too many attempts. Try again later.",
- "codeExpired": "That code has expired. Send a new one.",
+ "codeExpired": "That code is wrong or has expired. Check it or send a new one.",
"network": "Can't reach DebateLab. Check your connection and try again."
}
},
diff --git a/src/locales/es.json b/src/locales/es.json
index e775892d9..a23fccd47 100644
--- a/src/locales/es.json
+++ b/src/locales/es.json
@@ -545,6 +545,7 @@
"failed": "No se pudo iniciar sesión",
"tryAgain": "Inténtalo de nuevo.",
"backHome": "Volver a DebateLab",
+ "sessionExpired": "Tu sesión ha caducado. Vuelve a iniciar sesión.",
"errors": {
"demoNotFound": "No se encontró el código de demostración.",
"demoUnusable": "Este código de demostración no se puede usar.",
@@ -552,7 +553,7 @@
"demoExpired": "Este código de demostración ha caducado.",
"demoUsedUp": "Este código de demostración ya no tiene usos.",
"rateLimited": "Demasiados intentos. Inténtalo más tarde.",
- "codeExpired": "Ese código ha caducado. Solicita uno nuevo.",
+ "codeExpired": "Ese código es incorrecto o ha caducado. Revísalo o solicita uno nuevo.",
"network": "No se puede conectar con DebateLab. Revisa tu conexión e inténtalo de nuevo."
}
},
diff --git a/src/locales/hu.json b/src/locales/hu.json
index d685cb600..9132668a8 100644
--- a/src/locales/hu.json
+++ b/src/locales/hu.json
@@ -545,6 +545,7 @@
"failed": "Nem sikerült elindítani a bejelentkezést",
"tryAgain": "Próbáld újra.",
"backHome": "Vissza a DebateLabhez",
+ "sessionExpired": "A munkameneted lejárt. Jelentkezz be újra.",
"errors": {
"demoNotFound": "Nincs ilyen demókód.",
"demoUnusable": "Ez a demókód nem használható.",
@@ -552,7 +553,7 @@
"demoExpired": "Ez a demókód lejárt.",
"demoUsedUp": "Ezzel a demókóddal már nem lehet többször belépni.",
"rateLimited": "Túl sok próbálkozás. Próbáld újra később.",
- "codeExpired": "Ez a kód lejárt. Kérj újat.",
+ "codeExpired": "Ez a kód hibás vagy lejárt. Ellenőrizd, vagy kérj újat.",
"network": "A DebateLab nem érhető el. Ellenőrizd a kapcsolatot, és próbáld újra."
}
},
diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx
index cf2931dab..f07fcf9f7 100644
--- a/src/pages/LoginPage.tsx
+++ b/src/pages/LoginPage.tsx
@@ -11,12 +11,19 @@ import { useAuth } from '@/lib/supabase/AuthProvider';
import { useToast } from '@/hooks/use-toast';
import { consumeWorkspaceInvite } from '@/lib/workspace/invite-handoff';
import { localizeAuthError } from '@/lib/auth-errors';
+import { clearSessionExpiredNotice, sessionExpiredNotice } from '@/lib/supabase/session-expiry';
type AuthMode = 'email' | 'demo';
type AuthStatus = 'idle' | 'submitting' | 'code-sent' | 'verifying' | 'success' | 'error';
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const DEMO_CODE_PATTERN = /^DEMO-[A-Z0-9]{4}-[A-Z0-9]{4}$/;
+const OTP_LENGTH = 6;
+
+/** Email codes are digits only. Pasted spaces, dashes and letters never reach Auth. */
+function otpDigits(value: string): string {
+ return value.replace(/\D/g, '').slice(0, OTP_LENGTH);
+}
function isAuthMode(value: string): value is AuthMode {
return value === 'email' || value === 'demo';
@@ -32,6 +39,8 @@ export default function LoginPage() {
const [emailSent, setEmailSent] = useState(false);
const [demoCode, setDemoCode] = useState('');
const [errorMessage, setErrorMessage] = useState('');
+ // Set when the server refused this tab's session and it was signed out.
+ const [sessionExpired] = useState(sessionExpiredNotice);
const otpInputRef = useRef(null);
const emailInputRef = useRef(null);
const demoInputRef = useRef(null);
@@ -45,6 +54,10 @@ export default function LoginPage() {
if (user) navigate(consumeWorkspaceInvite() ?? from, { replace: true });
}, [user, navigate, from]);
+ useEffect(() => {
+ if (sessionExpired) clearSessionExpiredNotice();
+ }, [sessionExpired]);
+
useEffect(() => {
if (authStatus === 'code-sent') otpInputRef.current?.focus();
}, [authStatus]);
@@ -179,6 +192,11 @@ export default function LoginPage() {
{t('login.subtitle')}
+ {sessionExpired && (
+
+ {t('login.sessionExpired', 'Your session expired. Sign in again.')}
+
+ )}
void handleGoogleSignIn()} disabled={isRedirecting}>
{isRedirecting ? t('login.redirecting', 'Redirecting...') : t('login.google')}
@@ -212,9 +230,8 @@ export default function LoginPage() {
autoComplete="one-time-code"
placeholder={t('login.verificationCodePlaceholder', '123456')}
pattern="[0-9]*"
- maxLength={6}
value={otp}
- onChange={(event) => setOtp(event.target.value)}
+ onChange={(event) => setOtp(otpDigits(event.target.value))}
aria-describedby={describedBy('login-otp-hint', 'login-otp-error')}
aria-invalid={authStatus === 'error'}
disabled={isSubmitting || authStatus === 'success'}
@@ -224,7 +241,7 @@ export default function LoginPage() {
{t('login.codeSentDesc', 'Check your email for the six-digit code.')}
-
+
{authStatus === 'verifying' ? t('login.verifying', 'Verifying...') : t('login.verifyCode', 'Verify code')}
resetAuthState('email')} disabled={isSubmitting}>
diff --git a/src/test/auth/login-otp.test.tsx b/src/test/auth/login-otp.test.tsx
new file mode 100644
index 000000000..c299d9bd0
--- /dev/null
+++ b/src/test/auth/login-otp.test.tsx
@@ -0,0 +1,59 @@
+import { describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import { AuthApiError } from '@supabase/supabase-js';
+import LoginPage from '@/pages/LoginPage';
+
+const auth = vi.hoisted(() => ({
+ signInWithEmailOtp: vi.fn(async () => undefined),
+ verifyEmailOtp: vi.fn(async () => undefined),
+}));
+
+vi.mock('@/lib/supabase/AuthProvider', () => ({
+ useAuth: () => ({
+ user: null as null,
+ signInWithGoogle: vi.fn(),
+ signInWithEmailOtp: auth.signInWithEmailOtp,
+ verifyEmailOtp: auth.verifyEmailOtp,
+ signInWithDemoCode: vi.fn(),
+ }),
+}));
+
+async function requestCode() {
+ render( );
+ fireEvent.change(screen.getByLabelText('Email address'), { target: { value: 'student@example.com' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Send code' }));
+ return await screen.findByLabelText('Verification code');
+}
+
+describe('email code entry', () => {
+ it('keeps only digits and enables Verify at exactly six', async () => {
+ const input = await requestCode();
+ const verify = screen.getByRole('button', { name: 'Verify code' });
+
+ fireEvent.change(input, { target: { value: '12ab' } });
+ expect(input).toHaveValue('12');
+ expect(verify).toBeDisabled();
+
+ fireEvent.change(input, { target: { value: '12345' } });
+ expect(verify).toBeDisabled();
+
+ // A pasted code with a space still fills all six digits.
+ fireEvent.change(input, { target: { value: '123 4567' } });
+ expect(input).toHaveValue('123456');
+ expect(verify).toBeEnabled();
+
+ fireEvent.click(verify);
+ await waitFor(() => expect(auth.verifyEmailOtp).toHaveBeenCalledWith('student@example.com', '123456'));
+ });
+
+ it('says a refused code may be wrong, not only expired', async () => {
+ auth.verifyEmailOtp.mockRejectedValueOnce(new AuthApiError('Token has expired or is invalid', 403, 'otp_expired'));
+ const input = await requestCode();
+
+ fireEvent.change(input, { target: { value: '000000' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Verify code' }));
+
+ expect(await screen.findByRole('alert')).toHaveTextContent('That code is wrong or has expired. Check it or send a new one.');
+ });
+});
diff --git a/src/test/auth/session-expiry.test.ts b/src/test/auth/session-expiry.test.ts
new file mode 100644
index 000000000..bce80c2f0
--- /dev/null
+++ b/src/test/auth/session-expiry.test.ts
@@ -0,0 +1,122 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { AuthApiError, AuthRetryableFetchError, AuthSessionMissingError } from '@supabase/supabase-js';
+import { isAuthRejection, setActiveAccessToken, supabase, SUPABASE_URL } from '@/lib/supabase/client';
+import {
+ clearSessionExpiredNotice,
+ resetSessionExpiryCheck,
+ sessionExpiredNotice,
+ verifySessionAfterRejection,
+} from '@/lib/supabase/session-expiry';
+
+function fakeAuth(refreshError: unknown) {
+ return {
+ refreshSession: vi.fn(async () => ({ data: { user: null, session: null }, error: refreshError })),
+ signOut: vi.fn(async () => ({ error: null })),
+ };
+}
+
+describe('a refused session', () => {
+ beforeEach(() => {
+ resetSessionExpiryCheck();
+ clearSessionExpiredNotice();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ setActiveAccessToken(null, null);
+ });
+
+ it('counts only auth refusals, not policy answers', () => {
+ expect(isAuthRejection(`${SUPABASE_URL}/auth/v1/user`, 403)).toBe(true);
+ expect(isAuthRejection(`${SUPABASE_URL}/auth/v1/user`, 401)).toBe(true);
+ expect(isAuthRejection(`${SUPABASE_URL}/rest/v1/flowcharts?select=*`, 401)).toBe(true);
+ expect(isAuthRejection(`${SUPABASE_URL}/functions/v1/announcements`, 401)).toBe(true);
+ // RLS refusals and server errors are not a dead session.
+ expect(isAuthRejection(`${SUPABASE_URL}/rest/v1/flowcharts`, 403)).toBe(false);
+ expect(isAuthRejection(`${SUPABASE_URL}/rest/v1/flowcharts`, 503)).toBe(false);
+ // The check itself calls these; counting them would loop.
+ expect(isAuthRejection(`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, 400)).toBe(false);
+ expect(isAuthRejection(`${SUPABASE_URL}/auth/v1/logout?scope=local`, 401)).toBe(false);
+ });
+
+ it('signs out locally and leaves a note when Auth refuses the refresh too', async () => {
+ const auth = fakeAuth(new AuthApiError('Invalid Refresh Token: Refresh Token Not Found', 400, 'refresh_token_not_found'));
+
+ await expect(verifySessionAfterRejection(auth)).resolves.toBe('expired');
+
+ expect(auth.signOut).toHaveBeenCalledWith({ scope: 'local' });
+ expect(sessionExpiredNotice()).toBe(true);
+ });
+
+ it('treats a session missing from storage as expired', async () => {
+ const auth = fakeAuth(new AuthSessionMissingError());
+ await expect(verifySessionAfterRejection(auth)).resolves.toBe('expired');
+ expect(auth.signOut).toHaveBeenCalledOnce();
+ });
+
+ it.each([
+ ['an Auth outage', new AuthRetryableFetchError('Service Unavailable', 503)],
+ ['a rate limit', new AuthApiError('Too many requests', 429, 'over_request_rate_limit')],
+ ['an Auth server error', new AuthApiError('Internal error', 500, 'unexpected_failure')],
+ ])('keeps the session on %s', async (_label, error) => {
+ const auth = fakeAuth(error);
+
+ await expect(verifySessionAfterRejection(auth)).resolves.toBe('kept');
+
+ expect(auth.signOut).not.toHaveBeenCalled();
+ expect(sessionExpiredNotice()).toBe(false);
+ });
+
+ it('keeps a session whose refresh works, and does not refresh again for the rest of the burst', async () => {
+ let now = 1_000_000;
+ const auth = fakeAuth(null);
+
+ await expect(verifySessionAfterRejection(auth, () => now)).resolves.toBe('kept');
+ now += 9_000;
+ await expect(verifySessionAfterRejection(auth, () => now)).resolves.toBe('skipped');
+ now += 2_000;
+ await expect(verifySessionAfterRejection(auth, () => now)).resolves.toBe('kept');
+
+ expect(auth.refreshSession).toHaveBeenCalledTimes(2);
+ expect(auth.signOut).not.toHaveBeenCalled();
+ });
+
+ it('runs one check at a time', async () => {
+ let finish!: () => void;
+ const auth = {
+ refreshSession: vi.fn(() => new Promise<{ data: { user: null; session: null }; error: null }>((resolve) => {
+ finish = () => resolve({ data: { user: null, session: null }, error: null });
+ })),
+ signOut: vi.fn(),
+ };
+
+ const first = verifySessionAfterRejection(auth);
+ await expect(verifySessionAfterRejection(auth)).resolves.toBe('skipped');
+ finish();
+ await expect(first).resolves.toBe('kept');
+ expect(auth.refreshSession).toHaveBeenCalledOnce();
+ });
+
+ it('checks the session after a refused signed-in request, and ignores signed-out ones', async () => {
+ vi.useFakeTimers();
+ vi.stubGlobal('fetch', vi.fn(async () => new Response('{"message":"JWT expired"}', {
+ status: 401,
+ headers: { 'Content-Type': 'application/json' },
+ })));
+ const refresh = vi.spyOn(supabase.auth, 'refreshSession').mockResolvedValue({ data: { user: null, session: null }, error: null } as never);
+
+ setActiveAccessToken(null, null);
+ await supabase.from('flowcharts').select('id');
+ await vi.runAllTimersAsync();
+ expect(refresh).not.toHaveBeenCalled();
+
+ setActiveAccessToken('revoked-token', 'user-1');
+ await supabase.from('flowcharts').select('id');
+ // Not inside the request: supabase-js may still hold its session lock there.
+ expect(refresh).not.toHaveBeenCalled();
+ await vi.runAllTimersAsync();
+ expect(refresh).toHaveBeenCalledOnce();
+ });
+});
From b5d33f2ed7985e92c4b4d16df68ff4df6bef1491 Mon Sep 17 00:00:00 2001
From: Adam
Date: Thu, 22 Oct 2026 11:44:29 +0000
Subject: [PATCH 02/12] Handle the daily AI limit the same way in Crossfire,
Sparring and Settings (M1, M2, L2)
M1: when Crossfire's first AI turn is refused for the daily limit, the round
goes back to setup (no clock) and the daily-limit dialog Ask uses opens.
A round with exchanges keeps its state and can still be ended. Ending a
round with no exchange by hand no longer shows an error.
M2: Sparring setup warns when today's remaining requests are fewer than the
round needs, and an opponent or judge turn refused for the limit opens the
same dialog.
L2: one shared dialog component for every 402 in these tools, a localized
inline message instead of the server's English text, and Settings shows
requests used today and when they refresh. The client count now leaves out
refused calls and jev_* helper rows, reads the account's own rate_limits
override, and is null (never blocking) when it cannot be read.
Co-Authored-By: Claude Opus 5.5
---
.../features/sparring/SparringSetup.tsx | 13 ++-
.../subscription/SubscriptionErrorModals.tsx | 29 +++++
src/components/features/subscription/index.ts | 1 +
src/hooks/use-subscription.ts | 48 ++++++++-
src/locales/en.json | 6 ++
src/locales/es.json | 6 ++
src/locales/hu.json | 6 ++
src/pages/CrossfirePage.tsx | 29 ++++-
src/pages/SettingsPage.tsx | 18 +++-
src/pages/SparringPage.tsx | 29 ++++-
.../crossfire/crossfire-daily-limit.test.tsx | 102 ++++++++++++++++++
src/test/pages/CrossfireVoice.test.tsx | 2 +
src/test/pages/SparringVoice.test.tsx | 6 ++
13 files changed, 282 insertions(+), 13 deletions(-)
create mode 100644 src/components/features/subscription/SubscriptionErrorModals.tsx
create mode 100644 src/test/crossfire/crossfire-daily-limit.test.tsx
diff --git a/src/components/features/sparring/SparringSetup.tsx b/src/components/features/sparring/SparringSetup.tsx
index ada909e88..df651d15e 100644
--- a/src/components/features/sparring/SparringSetup.tsx
+++ b/src/components/features/sparring/SparringSetup.tsx
@@ -39,6 +39,8 @@ interface SparringSetupProps {
/** Fill the motion from the Current Debate. */
debatePrefill?: boolean;
onStart: (values: SparringSetupValues) => void;
+ /** AI requests left today, or null when unknown. A round short of requests gets a warning. */
+ remainingRequests?: number | null;
isStarting?: boolean;
initialValues?: Partial;
}
@@ -49,7 +51,7 @@ const DIFFICULTIES: Array<{ id: SparringDifficulty; name: string }> = [
{ id: 'hard', name: 'Ruthless' },
];
-export function SparringSetup({ onStart, isStarting = false, initialValues, banner, debatePrefill = false, voiceAllowed = true }: SparringSetupProps) {
+export function SparringSetup({ onStart, remainingRequests = null, isStarting = false, initialValues, banner, debatePrefill = false, voiceAllowed = true }: SparringSetupProps) {
const { t } = useTranslation();
const [motion, setMotion] = useState(initialValues?.motion ?? '');
const motionRef = useRef(motion);
@@ -66,6 +68,8 @@ export function SparringSetup({ onStart, isStarting = false, initialValues, bann
const plannedTurns = useMemo(() => buildSparringTurns(format, userSide), [format, userSide]);
const aiRequests = plannedTurns.filter((turn) => turn.speaker === 'ai').length;
const totalRequests = aiRequests + 1;
+ // An estimate (the server can raise a limit the browser cannot see), so it warns rather than blocks.
+ const requestShortfall = remainingRequests !== null && remainingRequests < totalRequests;
const canStart = motion.trim().length >= 8 && !isStarting && isOnline;
const handleSubmit = (event: React.FormEvent) => {
@@ -255,6 +259,13 @@ export function SparringSetup({ onStart, isStarting = false, initialValues, bann
{isStarting ? t('sparring.setup.openingRoom', 'Opening room…') : t('sparring.setup.startRound', 'Start round')}
{!isStarting && }
+ {requestShortfall && (
+
+ {remainingRequests === 0
+ ? t('dailyLimit.sparringNone')
+ : t('dailyLimit.sparringShort', { needed: totalRequests, remaining: remainingRequests })}
+
+ )}
{!isOnline && {t('sparring.setup.offlineCannotStart', 'Reconnect to start a round. Your setup is kept while you are offline.')}
}
{t('sparring.setup.draftNotice', 'Your draft is saved locally as you type. You can pause without losing your place.')}
diff --git a/src/components/features/subscription/SubscriptionErrorModals.tsx b/src/components/features/subscription/SubscriptionErrorModals.tsx
new file mode 100644
index 000000000..782fc46f5
--- /dev/null
+++ b/src/components/features/subscription/SubscriptionErrorModals.tsx
@@ -0,0 +1,29 @@
+import type { useSubscriptionError } from '@/hooks/use-subscription-error';
+import { LimitReachedModal } from './LimitReachedModal';
+import { ModelRestrictedModal } from './ModelRestrictedModal';
+import { PricingModal } from './PricingModal';
+
+/**
+ * The daily-limit, Pro-model and pricing dialogs a tool opens from useSubscriptionError.
+ * Each mounts only while open, so a tool that never hits a limit never loads plan data for them.
+ */
+export function SubscriptionErrorModals({ subError }: { subError: ReturnType }) {
+ return (
+ <>
+ {subError.limitModalOpen && { subError.setLimitModalOpen(false); subError.setPricingModalOpen(true); }}
+ />}
+ {subError.modelModalOpen && { subError.setModelModalOpen(false); subError.setPricingModalOpen(true); }}
+ />}
+ {subError.pricingModalOpen && }
+ >
+ );
+}
diff --git a/src/components/features/subscription/index.ts b/src/components/features/subscription/index.ts
index 91600de15..a5ac7f632 100644
--- a/src/components/features/subscription/index.ts
+++ b/src/components/features/subscription/index.ts
@@ -1,3 +1,4 @@
export { PricingModal } from './PricingModal';
export { LimitReachedModal } from './LimitReachedModal';
export { ModelRestrictedModal } from './ModelRestrictedModal';
+export { SubscriptionErrorModals } from './SubscriptionErrorModals';
diff --git a/src/hooks/use-subscription.ts b/src/hooks/use-subscription.ts
index 4a599dee2..6119fca68 100644
--- a/src/hooks/use-subscription.ts
+++ b/src/hooks/use-subscription.ts
@@ -12,10 +12,14 @@ interface SubscriptionState {
hasStripeBilling: boolean;
dailyLimit: number;
dailyUsed: number;
+ /** AI requests left today, or null while it is unknown (loading, failed read, signed out). Never block on null. */
+ remainingToday: number | null;
trialEndsAt: string | null;
gracePeriodEndsAt: string | null;
loading: boolean;
refresh: () => Promise;
+ /** Re-reads usage and returns what is left today, or null when it cannot be read. */
+ checkRemainingToday: () => Promise;
}
interface BillingRow {
@@ -24,7 +28,9 @@ interface BillingRow {
current_period_end?: string | null;
}
-type SubscriptionSnapshot = Omit;
+type SubscriptionSnapshot = Omit & {
+ usageKnown: boolean;
+};
const SUBSCRIPTION_STALE_TIME_MS = 60_000;
const SIGNED_OUT_SNAPSHOT: SubscriptionSnapshot = {
@@ -32,6 +38,7 @@ const SIGNED_OUT_SNAPSHOT: SubscriptionSnapshot = {
hasStripeBilling: false,
dailyLimit: 5,
dailyUsed: 0,
+ usageKnown: false,
trialEndsAt: null,
gracePeriodEndsAt: null,
};
@@ -40,6 +47,7 @@ const SIGNED_IN_FALLBACK: SubscriptionSnapshot = {
hasStripeBilling: false,
dailyLimit: 0,
dailyUsed: 0,
+ usageKnown: false,
trialEndsAt: null,
gracePeriodEndsAt: null,
};
@@ -111,24 +119,51 @@ async function fetchSubscription(userId: string): Promise
}
}
+ // The server lets a per-account rate_limits row replace the plan's limit
+ // (getConfiguredDailyLimit). Global rows are server-only, so the remaining
+ // count is an estimate: warn with it, and let the server's 402 decide.
+ let limitKnown = true;
+ if (data && !(snapshot.tier === 'expired' && snapshot.dailyLimit === 0)) {
+ const { data: ownLimit, error: limitError } = await supabase
+ .from('rate_limits')
+ .select('limit_value')
+ .eq('user_id', userId)
+ .eq('limit_type', 'daily_requests')
+ .order('updated_at', { ascending: false })
+ .limit(1)
+ .maybeSingle();
+ if (limitError) limitKnown = false;
+ else if (typeof ownLimit?.limit_value === 'number') snapshot.dailyLimit = ownLimit.limit_value;
+ }
+
const todayStart = new Date();
todayStart.setUTCHours(0, 0, 0, 0);
+ // The server counts one request per AI call it lets through (consume_daily_quota).
+ // Each of those logs one row under its feature; refused calls and the
+ // jev_* helper steps inside a call log rows too, but use no quota.
const { count, error: usageError } = await supabase
.from('ai_requests')
.select('*', { count: 'exact', head: true })
.eq('user_id', userId)
- .gte('created_at', todayStart.toISOString());
+ .gte('created_at', todayStart.toISOString())
+ .neq('status', 'rate_limited')
+ .not('feature', 'like', 'jev_%');
if (usageError) {
console.warn('[useSubscription] Failed to load daily usage:', usageError.message);
}
- return { ...snapshot, dailyUsed: usageError ? 0 : count || 0 };
+ return { ...snapshot, dailyUsed: usageError ? 0 : count || 0, usageKnown: !usageError && limitKnown && Boolean(data) };
} catch (error) {
console.warn('[useSubscription] Failed to load:', error);
throw error;
}
}
+function remainingRequests(snapshot: SubscriptionSnapshot, usageKnown: boolean): number | null {
+ if (!usageKnown) return null;
+ return Math.max(0, snapshot.dailyLimit - snapshot.dailyUsed);
+}
+
export function useSubscription(): SubscriptionState {
const { user } = useAuth();
const userId = user?.id;
@@ -142,6 +177,7 @@ export function useSubscription(): SubscriptionState {
? query.isError ? SIGNED_IN_FALLBACK : query.data ?? SIGNED_IN_FALLBACK
: SIGNED_OUT_SNAPSHOT;
const { tier, dailyLimit, dailyUsed, trialEndsAt, gracePeriodEndsAt, hasStripeBilling } = snapshot;
+ const usageKnown = Boolean(userId) && !query.isError && snapshot.usageKnown;
const isPro = tier === 'pro' || tier === 'legacy_free';
const isTrialActive = tier === 'trial';
@@ -155,6 +191,7 @@ export function useSubscription(): SubscriptionState {
hasStripeBilling,
dailyLimit,
dailyUsed,
+ remainingToday: remainingRequests(snapshot, usageKnown),
trialEndsAt,
gracePeriodEndsAt,
loading: Boolean(userId) && query.isPending,
@@ -162,5 +199,10 @@ export function useSubscription(): SubscriptionState {
if (!userId) return;
await query.refetch();
},
+ checkRemainingToday: async () => {
+ if (!userId) return null;
+ const result = await query.refetch();
+ return result.isError || !result.data ? null : remainingRequests(result.data, result.data.usageKnown);
+ },
};
}
diff --git a/src/locales/en.json b/src/locales/en.json
index 9267fdf85..bf0b2bcdc 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -1351,6 +1351,12 @@
"upgrade": "Upgrade to Pro - $5/mo",
"useFree": "Use low reasoning instead"
},
+ "dailyLimit": {
+ "reachedInline": "You've used today's AI requests. They refresh at midnight UTC.",
+ "usage": "Today: {{used}} of {{limit}} AI requests used. They refresh at midnight UTC ({{time}} your time).",
+ "sparringNone": "You have no AI requests left today, so the opponent can't answer. They refresh at midnight UTC.",
+ "sparringShort": "This round needs {{needed}} AI requests and you have {{remaining}} left today, so it would stop before the judge. They refresh at midnight UTC."
+ },
"pricingModal": {
"title": "Upgrade to Pro",
"subtitle": "Unlock higher usage limits, all 10 models, and configurable reasoning.",
diff --git a/src/locales/es.json b/src/locales/es.json
index a23fccd47..1fdef627f 100644
--- a/src/locales/es.json
+++ b/src/locales/es.json
@@ -1351,6 +1351,12 @@
"upgrade": "Actualizar a Pro - $5/mes",
"useFree": "Usar razonamiento bajo"
},
+ "dailyLimit": {
+ "reachedInline": "Has usado tus solicitudes de IA de hoy. Se renuevan a medianoche UTC.",
+ "usage": "Hoy: {{used}} de {{limit}} solicitudes de IA usadas. Se renuevan a medianoche UTC ({{time}} en tu hora).",
+ "sparringNone": "No te quedan solicitudes de IA hoy, así que el oponente no podrá responder. Se renuevan a medianoche UTC.",
+ "sparringShort": "Esta ronda necesita {{needed}} solicitudes de IA y hoy te quedan {{remaining}}, así que se detendría antes del juez. Se renuevan a medianoche UTC."
+ },
"pricingModal": {
"title": "Actualizar a Pro",
"subtitle": "Desbloquea límites de uso más altos, los 10 modelos y razonamiento configurable.",
diff --git a/src/locales/hu.json b/src/locales/hu.json
index 9132668a8..eaf1bfcec 100644
--- a/src/locales/hu.json
+++ b/src/locales/hu.json
@@ -1351,6 +1351,12 @@
"upgrade": "Frissítés Pro-ra - $5/hó",
"useFree": "Alacsony szintű érvelés használata"
},
+ "dailyLimit": {
+ "reachedInline": "Elhasználtad a mai MI-kéréseidet. Éjfélkor (UTC) frissülnek.",
+ "usage": "Ma: {{used}} / {{limit}} MI-kérés felhasználva. Éjfélkor (UTC) frissülnek (nálad {{time}}).",
+ "sparringNone": "Mára nem maradt MI-kérésed, így az ellenfél nem tud válaszolni. Éjfélkor (UTC) frissülnek.",
+ "sparringShort": "Ehhez a körhöz {{needed}} MI-kérés kell, de ma csak {{remaining}} maradt, így a bíró előtt megállna. Éjfélkor (UTC) frissülnek."
+ },
"pricingModal": {
"title": "Frissítés Pro-ra",
"subtitle": "Oldd fel a magasabb használati keretet, mind a 10 modellt és a beállítható érvelést.",
diff --git a/src/pages/CrossfirePage.tsx b/src/pages/CrossfirePage.tsx
index 8cf65f17c..87f821346 100644
--- a/src/pages/CrossfirePage.tsx
+++ b/src/pages/CrossfirePage.tsx
@@ -23,7 +23,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
-import { crossfire, crossfireSignal, AIResponseError, isTimeoutError } from '@/lib/ai/client';
+import { crossfire, crossfireSignal, AIResponseError, isAISubscriptionError, isTimeoutError } from '@/lib/ai/client';
import { getUserFacingAIError } from '@/lib/ai/error-display';
import {
canEndCrossfireSession,
@@ -75,6 +75,8 @@ import { VoiceModeToggle } from '@/components/features/voice/VoiceModeToggle';
import { useVoiceAvailability } from '@/lib/voice/availability';
import { combineDelivery, measureDelivery } from '@/lib/voice/delivery';
import { opponentVoice } from '@/lib/voice/opponent-voice';
+import { useSubscriptionError } from '@/hooks/use-subscription-error';
+import { SubscriptionErrorModals } from '@/components/features/subscription';
const SESSION_ID_PREFIX = 'crossfire';
@@ -183,6 +185,7 @@ function classifyError(error: unknown): {
} {
if (isTimeoutError(error)) return { kind: 'timeout', message: error.message };
if (error instanceof AIResponseError) return { kind: 'response', message: error.message };
+ if (isAISubscriptionError(error) && error.code === 'limit_reached') return { kind: 'request', message: 'dailyLimit.reachedInline' };
return { kind: 'request', message: getUserFacingAIError(error) };
}
@@ -314,6 +317,8 @@ export default function CrossfirePage() {
const workspace = useWorkspaceToolScope('crossfire');
const { t } = useTranslation();
const { user } = useAuth();
+ const subError = useSubscriptionError();
+ const { handleSubscriptionError } = subError;
const location = useLocation();
const navigate = useNavigate();
const workspaceLaunch = (location.state as { workspaceLaunch?: unknown } | null)?.workspaceLaunch === true;
@@ -512,12 +517,27 @@ export default function CrossfirePage() {
}
} catch (error) {
if (controller.signal.aborted) return;
+ // Out of requests or not allowed this model: the same dialog every tool uses.
+ if (handleSubscriptionError(error) && snapshot.turns.length === 0) {
+ // Nothing was exchanged, so the round never really started: no clock, back to setup.
+ opponentVoice.stop();
+ useDebateContextStore.getState().resetOverride('crossfire');
+ dispatch({
+ type: 'session-reset',
+ prefill: {
+ resolution: snapshot.config.resolution,
+ argument: snapshot.config.argument,
+ seedQuestions: snapshot.config.seedQuestions,
+ },
+ });
+ return;
+ }
const classified = classifyError(error);
dispatch({ type: 'request-failed', expectedReply, errorKind: classified.kind, message: classified.message });
} finally {
if (requestRef.current === controller) setLiveReply('');
}
- }, [modelConfig, roundDebateId, voiceActive, voiceLanguage, workspace.context]);
+ }, [handleSubscriptionError, modelConfig, roundDebateId, voiceActive, voiceLanguage, workspace.context]);
const runDebrief = useCallback(async (snapshot: Extract) => {
requestRef.current?.abort();
@@ -573,7 +593,8 @@ export default function CrossfirePage() {
seedQuestions: state.config.seedQuestions,
},
});
- setFormError(t('crossfire.debriefEmptyDesc'));
+ // Ending by hand before any exchange just leaves; only the clock running out needs explaining.
+ setFormError(reason === 'timer' ? t('crossfire.debriefEmptyDesc') : '');
return;
}
const next = crossfireReducer(state, { type: 'session-ended', now: Date.now(), reason });
@@ -697,6 +718,7 @@ export default function CrossfirePage() {
{debateGate.dialog}
+
{(formError || storageError) && (
@@ -1061,6 +1083,7 @@ export default function CrossfirePage() {
)}
+
);
}
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx
index 271dd7e23..1e25a3ce1 100644
--- a/src/pages/SettingsPage.tsx
+++ b/src/pages/SettingsPage.tsx
@@ -48,13 +48,20 @@ const tierLabels: Record = {
expired: { labelKey: 'settings.tierExpired', color: 'border-destructive/30 bg-destructive/10 text-destructive' },
};
+/** The next midnight UTC, when the daily AI requests refresh, on the reader's clock. */
+function dailyResetTime(locale: string): string {
+ const now = new Date();
+ const reset = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1));
+ return reset.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit' });
+}
+
export default function SettingsPage() {
const { language, setLanguage, outputLanguage, setOutputLanguage, theme, setTheme } = useSettingsStore();
const { clearSession, clearAllData } = useSessionStore();
const resetSparringLocalData = useSparringStore((state) => state.resetLocalData);
const { user, signOut } = useAuth();
const { toast } = useToast();
- const { t } = useTranslation();
+ const { t, i18n } = useTranslation();
const subscription = useSubscription();
const [pricingOpen, setPricingOpen] = useState(false);
const [portalLoading, setPortalLoading] = useState(false);
@@ -279,6 +286,15 @@ export default function SettingsPage() {
{t(subscription.tier === 'expired' ? 'settings.accessExpired' : 'settings.accessActive')}
+ {subscription.remainingToday !== null && subscription.dailyLimit > 0 && (
+
+ {t('dailyLimit.usage', {
+ used: Math.min(subscription.dailyUsed, subscription.dailyLimit),
+ limit: subscription.dailyLimit,
+ time: dailyResetTime(i18n.language),
+ })}
+
+ )}
>
)}
diff --git a/src/pages/SparringPage.tsx b/src/pages/SparringPage.tsx
index 8128f7868..e6199517a 100644
--- a/src/pages/SparringPage.tsx
+++ b/src/pages/SparringPage.tsx
@@ -5,7 +5,7 @@ import { ArrowLeft, CircleHelp } from 'lucide-react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useSparringStore } from '@/lib/stores/sparring-store';
import { useSettingsStore } from '@/lib/stores/settings-store';
-import { generateSparringTurn, judgeSparringSession } from '@/lib/ai/client';
+import { generateSparringTurn, isAISubscriptionError, judgeSparringSession } from '@/lib/ai/client';
import type { SparringRespondInput, SparringScoreInput, SparringSpeech } from '@/lib/ai/schemas/sparring';
import { SparringSetup, type SparringSetupValues } from '@/components/features/sparring/SparringSetup';
import { SparringRound } from '@/components/features/sparring/SparringRound';
@@ -24,12 +24,19 @@ import { OpponentVoiceBar } from '@/components/features/voice/OpponentVoiceBar';
import { useVoiceAvailability } from '@/lib/voice/availability';
import type { SpeechDelivery } from '@/lib/voice/delivery';
import { opponentVoice } from '@/lib/voice/opponent-voice';
+import { useSubscription } from '@/hooks/use-subscription';
+import { useSubscriptionError } from '@/hooks/use-subscription-error';
+import { SubscriptionErrorModals } from '@/components/features/subscription';
function readableError(error: unknown, fallback: string) {
if (error instanceof Error && error.message.trim()) return error.message;
return fallback;
}
+function isDailyLimitError(error: unknown) {
+ return isAISubscriptionError(error) && error.code === 'limit_reached';
+}
+
function estimateAiTimeUsedSeconds(content: string, limit: number) {
const words = content.trim() ? content.trim().split(/\s+/).length : 0;
return Math.min(limit, Math.max(1, Math.round((words / 140) * 60)));
@@ -41,6 +48,9 @@ export default function SparringPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
+ const { remainingToday } = useSubscription();
+ const subError = useSubscriptionError();
+ const { handleSubscriptionError } = subError;
const workspaceLaunch = (location.state as { workspaceLaunch?: unknown } | null)?.workspaceLaunch === true;
const workspaceArtifact = (location.state as { workspaceArtifact?: unknown } | null)?.workspaceArtifact;
const storedSession = useSparringStore((state) => state.currentSession);
@@ -207,7 +217,10 @@ export default function SparringPage() {
})
.catch((error: unknown) => {
if (aiRequestRef.current !== requestKey) return;
- setAiError(readableError(error, t('sparring.round.aiRequestFailed', 'The AI request failed. Check your connection and try again.')));
+ handleSubscriptionError(error);
+ setAiError(isDailyLimitError(error)
+ ? t('dailyLimit.reachedInline')
+ : readableError(error, t('sparring.round.aiRequestFailed', 'The AI request failed. Check your connection and try again.')));
})
.finally(() => {
if (aiRequestRef.current === requestKey) {
@@ -216,7 +229,7 @@ export default function SparringPage() {
setLiveSpeech('');
}
});
- }, [appendAiSpeech, config, outputLanguage, pauseTimer, resumeTimer, retryNonce, session, t, voiceActive, workspace.context]);
+ }, [appendAiSpeech, config, handleSubscriptionError, outputLanguage, pauseTimer, resumeTimer, retryNonce, session, t, voiceActive, workspace.context]);
useEffect(() => {
if (!session || session.phase !== 'judging') return;
@@ -257,7 +270,10 @@ export default function SparringPage() {
})
.catch((error: unknown) => {
if (judgeRequestRef.current !== requestKey) return;
- setJudgingError(readableError(error, t('sparring.round.judgeRequestFailed', 'The judge request failed. Check your connection and try again.')));
+ handleSubscriptionError(error);
+ setJudgingError(isDailyLimitError(error)
+ ? t('dailyLimit.reachedInline')
+ : readableError(error, t('sparring.round.judgeRequestFailed', 'The judge request failed. Check your connection and try again.')));
})
.finally(() => {
if (judgeRequestRef.current === requestKey) {
@@ -265,7 +281,7 @@ export default function SparringPage() {
setLiveJudging({});
}
});
- }, [completeSession, config, outputLanguage, retryNonce, session, t, workspace.context]);
+ }, [completeSession, config, handleSubscriptionError, outputLanguage, retryNonce, session, t, workspace.context]);
useEffect(() => {
if (!workspace.context || !session) return;
@@ -333,11 +349,13 @@ export default function SparringPage() {
}
/>
{debateGate.dialog}
+
);
}
@@ -379,6 +397,7 @@ export default function SparringPage() {
onRetryAi={retryAi}
onRetryJudging={retryJudging}
/>
+
);
}
diff --git a/src/test/crossfire/crossfire-daily-limit.test.tsx b/src/test/crossfire/crossfire-daily-limit.test.tsx
new file mode 100644
index 000000000..dce4a6105
--- /dev/null
+++ b/src/test/crossfire/crossfire-daily-limit.test.tsx
@@ -0,0 +1,102 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mocks = vi.hoisted(() => ({
+ crossfire: vi.fn<(input: { operation: string }) => Promise>(),
+}));
+
+vi.mock('@/lib/supabase/AuthProvider', () => ({
+ useAuth: () => ({ user: { id: 'user-a' } }),
+}));
+
+vi.mock('@/hooks/use-subscription', () => ({
+ useSubscription: () => ({ isPro: false, tier: 'trial', loading: false }),
+}));
+
+vi.mock('@/lib/stores/session-store', () => ({
+ useSessionStore: (selector: (state: { sourceLibrary: unknown[] }) => unknown) => selector({ sourceLibrary: [] }),
+}));
+
+vi.mock('@/lib/stores/settings-store', () => ({
+ useSettingsStore: () => ({
+ getFeatureConfig: () => ({ provider: 'google', model: 'gemini-3-flash-preview' }),
+ outputLanguage: 'en',
+ }),
+}));
+
+vi.mock('@/lib/ai/client', async () => {
+ const errors = await vi.importActual('@/lib/ai/errors');
+ return {
+ AIResponseError: errors.AIResponseError,
+ AISubscriptionError: errors.AISubscriptionError,
+ isAISubscriptionError: errors.isAISubscriptionError,
+ isAIModelRestrictionError: errors.isAIModelRestrictionError,
+ crossfire: mocks.crossfire,
+ crossfireSignal: vi.fn(() => Promise.resolve(undefined)),
+ isTimeoutError: () => false,
+ };
+});
+
+import CrossfirePage from '@/pages/CrossfirePage';
+import { AISubscriptionError } from '@/lib/ai/client';
+import { enableOneOffMode } from '@/test/helpers/debate';
+
+function outOfRequests() {
+ return new AISubscriptionError("You've used all 5 of your daily requests. Upgrade to Pro for 50 daily requests.", {
+ statusCode: 402,
+ code: 'limit_reached',
+ limit: 5,
+ current: 5,
+ });
+}
+
+function startRound(mode: 'question-ai' | 'ai-questions-user') {
+ render( );
+ fireEvent.change(screen.getByTestId('crossfire-resolution'), { target: { value: 'Resolved: test' } });
+ fireEvent.change(screen.getByTestId('crossfire-argument'), { target: { value: 'Case text' } });
+ fireEvent.click(screen.getByTestId(`mode-${mode}`));
+ fireEvent.click(screen.getByTestId('start-crossfire'));
+}
+
+describe('Crossfire out of daily requests', () => {
+ beforeEach(() => {
+ enableOneOffMode();
+ vi.clearAllMocks();
+ localStorage.clear();
+ });
+
+ it('does not start the round or its clock when the first AI turn is refused, and opens the daily-limit dialog', async () => {
+ mocks.crossfire.mockRejectedValueOnce(outOfRequests());
+ startRound('ai-questions-user');
+
+ expect(await screen.findByRole('dialog')).toHaveTextContent('5 of 5');
+ expect(screen.queryByTestId('end-crossfire')).not.toBeInTheDocument();
+ expect(screen.getByTestId('start-crossfire')).toBeInTheDocument();
+ expect(screen.getByTestId('crossfire-resolution')).toHaveValue('Resolved: test');
+ });
+
+ it('keeps a round that already has an exchange, so it can still be ended and debriefed', async () => {
+ mocks.crossfire
+ .mockResolvedValueOnce({ type: 'question', text: 'Who pays the cost?' })
+ .mockRejectedValueOnce(outOfRequests());
+ startRound('ai-questions-user');
+
+ fireEvent.change(await screen.findByTestId('crossfire-input'), { target: { value: 'The travellers do.' } });
+ fireEvent.click(screen.getByTestId('submit-turn'));
+
+ expect(await screen.findByRole('dialog')).toBeInTheDocument();
+ expect(screen.getByText("You've used today's AI requests. They refresh at midnight UTC.")).toBeInTheDocument();
+ expect(screen.getByTestId('end-crossfire')).toBeEnabled();
+ });
+
+ it('lets a round with no exchange be ended by hand without an error', async () => {
+ mocks.crossfire.mockImplementation(() => new Promise(() => undefined));
+ startRound('ai-questions-user');
+
+ fireEvent.click(await screen.findByTestId('end-crossfire'));
+
+ expect(screen.getByTestId('start-crossfire')).toBeInTheDocument();
+ expect(screen.queryByText('Complete at least one exchange before ending the session.')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/test/pages/CrossfireVoice.test.tsx b/src/test/pages/CrossfireVoice.test.tsx
index 75c62e1a6..ef311c5fd 100644
--- a/src/test/pages/CrossfireVoice.test.tsx
+++ b/src/test/pages/CrossfireVoice.test.tsx
@@ -34,6 +34,8 @@ vi.mock('@/lib/ai/client', () => ({
crossfire: mocks.crossfire,
crossfireSignal: mocks.crossfireSignal,
isTimeoutError: () => false,
+ isAISubscriptionError: () => false,
+ isAIModelRestrictionError: () => false,
}));
vi.mock('@/lib/voice/opponent-voice', () => ({
diff --git a/src/test/pages/SparringVoice.test.tsx b/src/test/pages/SparringVoice.test.tsx
index 376b8e359..fd1317350 100644
--- a/src/test/pages/SparringVoice.test.tsx
+++ b/src/test/pages/SparringVoice.test.tsx
@@ -24,6 +24,12 @@ vi.mock('@/lib/supabase/client', () => ({
vi.mock('@/lib/ai/client', () => ({
generateSparringTurn: generateMock,
judgeSparringSession: judgeMock,
+ isAISubscriptionError: () => false,
+ isAIModelRestrictionError: () => false,
+}));
+
+vi.mock('@/hooks/use-subscription', () => ({
+ useSubscription: () => ({ remainingToday: null as number | null }),
}));
vi.mock('@/lib/voice/opponent-voice', () => ({
From 2af5295e9a3cba91b5cd48a9524abc81bd0374b5 Mon Sep 17 00:00:00 2001
From: Adam
Date: Thu, 22 Oct 2026 11:46:24 +0000
Subject: [PATCH 03/12] Save each researched page once per debate (M3)
Auto-saved sources are matched per debate by a normalized URL (host
lower-cased without www, no scheme, fragment, tracking parameters or trailing
slash). A page already in the debate's Library gets its research metadata
refreshed instead of a second row; the student's title, folder and on/off
choice stay. Batches from tools that finish together run one after another so
each sees the rows the last one saved.
Co-Authored-By: Claude Opus 5.5
---
src/lib/ai/sources.test.ts | 92 ++++++++++++++++++++++++++++++++++++++
src/lib/ai/sources.ts | 92 ++++++++++++++++++++++++++++++++------
2 files changed, 170 insertions(+), 14 deletions(-)
create mode 100644 src/lib/ai/sources.test.ts
diff --git a/src/lib/ai/sources.test.ts b/src/lib/ai/sources.test.ts
new file mode 100644
index 000000000..21ee55b6d
--- /dev/null
+++ b/src/lib/ai/sources.test.ts
@@ -0,0 +1,92 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { autosaveDiscoveredSources, normalizeSourceUrl } from './sources';
+import { useSessionStore, type Source } from '@/lib/stores/session-store';
+
+const DEBATE = 'debate-1';
+const PAGE = 'https://www.sciencemediacentre.es/en/homework-primary-school/';
+
+describe('normalizeSourceUrl', () => {
+ it('gives one key to the variants research returns for the same page', () => {
+ const key = normalizeSourceUrl(PAGE);
+ expect(key).toBe('sciencemediacentre.es/en/homework-primary-school');
+ for (const variant of [
+ 'https://sciencemediacentre.es/en/homework-primary-school',
+ 'http://WWW.ScienceMediaCentre.es/en/homework-primary-school/#key-points',
+ 'https://www.sciencemediacentre.es/en/homework-primary-school?utm_source=x&utm_medium=y&fbclid=abc',
+ ]) expect(normalizeSourceUrl(variant)).toBe(key);
+ });
+
+ it('keeps parameters and paths that name a different page', () => {
+ expect(normalizeSourceUrl('https://example.org/article?id=1')).not.toBe(normalizeSourceUrl('https://example.org/article?id=2'));
+ expect(normalizeSourceUrl('https://example.org/en/a')).not.toBe(normalizeSourceUrl('https://example.org/es/a'));
+ expect(normalizeSourceUrl('https://news.example.org/a')).not.toBe(normalizeSourceUrl('https://example.org/a'));
+ expect(normalizeSourceUrl('not a url')).toBeNull();
+ });
+});
+
+describe('autosaveDiscoveredSources', () => {
+ type Store = ReturnType;
+ const addSource = vi.fn();
+ const updateSource = vi.fn();
+ const original = useSessionStore.getState();
+
+ beforeEach(() => {
+ addSource.mockReset();
+ updateSource.mockReset();
+ // The stubbed write lands in the library like the real one, a moment later.
+ addSource.mockImplementation(async (source) => {
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ const saved: Source = {
+ ...source,
+ id: `row-${useSessionStore.getState().sourceLibrary.length + 1}`,
+ folder: source.folder ?? 'General',
+ debateId: source.debateId ?? null,
+ isActive: source.isActive ?? true,
+ createdAt: Date.now(),
+ };
+ useSessionStore.setState((state) => ({ sourceLibrary: [saved, ...state.sourceLibrary] }));
+ });
+ useSessionStore.setState({ sourceLibrary: [], addSource, updateSource });
+ });
+
+ afterEach(() => {
+ useSessionStore.setState({ sourceLibrary: [], addSource: original.addSource, updateSource: original.updateSource });
+ });
+
+ it('saves a page once per debate when two tools find it at the same time, and updates it after', async () => {
+ const generate = autosaveDiscoveredSources([
+ { title: 'Homework study', url: PAGE, content: 'Findings', qualityLabel: 'strong' },
+ { title: 'Homework study (again)', url: `${PAGE}#methods`, qualityLabel: 'strong' },
+ ], 'generate', DEBATE);
+ const brainstorm = autosaveDiscoveredSources([
+ { title: 'Homework study', url: 'https://sciencemediacentre.es/en/homework-primary-school?utm_source=exa', qualityLabel: 'moderate', query: 'homework' },
+ ], 'brainstorm', DEBATE);
+ await Promise.all([generate, brainstorm]);
+
+ expect(addSource).toHaveBeenCalledOnce();
+ expect(updateSource).toHaveBeenCalledOnce();
+ const [id, patch] = updateSource.mock.calls[0];
+ expect(id).toBe('row-1');
+ expect(patch.metadata).toMatchObject({ capturedVia: 'brainstorm', query: 'homework', qualityLabel: 'moderate' });
+ // The student's title, folder and on/off choice are theirs.
+ expect(patch).not.toHaveProperty('title');
+ expect(patch).not.toHaveProperty('isActive');
+ expect(patch).not.toHaveProperty('folder');
+ });
+
+ it('saves the same page again in a different debate', async () => {
+ await autosaveDiscoveredSources([{ title: 'Homework study', url: PAGE }], 'generate', DEBATE);
+ await autosaveDiscoveredSources([{ title: 'Homework study', url: PAGE }], 'attack', 'debate-2');
+
+ expect(addSource).toHaveBeenCalledTimes(2);
+ expect(updateSource).not.toHaveBeenCalled();
+ });
+
+ it('keeps going after a failed batch', async () => {
+ addSource.mockRejectedValueOnce(new Error('offline'));
+ await autosaveDiscoveredSources([{ title: 'A', url: 'https://example.org/a' }], 'generate', DEBATE);
+ await autosaveDiscoveredSources([{ title: 'B', url: 'https://example.org/b' }], 'generate', DEBATE);
+
+ expect(addSource).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/src/lib/ai/sources.ts b/src/lib/ai/sources.ts
index dc6ae0348..6a1feac9e 100644
--- a/src/lib/ai/sources.ts
+++ b/src/lib/ai/sources.ts
@@ -117,18 +117,90 @@ export function parseSourceAssessments(value: unknown): SourceAssessmentUpdate[]
type AutosaveFeature = 'generate' | 'attack' | 'brainstorm' | 'factoids';
-export async function autosaveDiscoveredSources(sources: DiscoveredSource[], capturedVia: AutosaveFeature, debateId: string | null | undefined): Promise {
+// Query parameters that only say where a click came from. Two links that differ
+// only in these open the same page.
+const TRACKING_PARAMS = /^(utm_.+|fbclid|gclid|dclid|gbraid|wbraid|msclkid|yclid|mc_cid|mc_eid|igshid|_ga|_gl|ref_src)$/i;
+
+/**
+ * One key per web page: the host lower-cased without "www.", no scheme, no
+ * fragment, no tracking parameters, no trailing slash. Null when it is not a URL.
+ */
+export function normalizeSourceUrl(url: string): string | null {
+ let parsed: URL;
+ try {
+ parsed = new URL(url.trim());
+ } catch {
+ return null;
+ }
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
+ const host = parsed.host.toLowerCase().replace(/^www\./, '');
+ const params = new URLSearchParams();
+ parsed.searchParams.forEach((value, key) => {
+ if (!TRACKING_PARAMS.test(key)) params.append(key, value);
+ });
+ const query = params.toString();
+ const path = parsed.pathname.replace(/\/+$/, '');
+ return `${host}${path}${query ? `?${query}` : ''}`;
+}
+
+function savedSourceUrl(source: { origin?: string; metadata?: { url?: string } }): string | null {
+ const url = source.metadata?.url ?? source.origin;
+ return url ? normalizeSourceUrl(url) : null;
+}
+
+// Generate, Attack, Brainstorm and Factoids can finish at the same time. Each
+// batch waits for the one before it, so it sees the rows that batch saved.
+let autosaveQueue: Promise = Promise.resolve();
+
+export function autosaveDiscoveredSources(sources: DiscoveredSource[], capturedVia: AutosaveFeature, debateId: string | null | undefined): Promise {
const targetDebateId = debateId !== undefined ? debateId : activeDebateId();
+ const run = autosaveQueue.then(() => autosaveBatch(sources, capturedVia, targetDebateId));
+ autosaveQueue = run.catch((): void => undefined);
+ return run;
+}
+
+async function autosaveBatch(sources: DiscoveredSource[], capturedVia: AutosaveFeature, targetDebateId: string | null): Promise {
// Every source belongs to one debate, so one-off research is not kept.
if (!targetDebateId) return;
- const retainedSources = sources.filter((source) => !source.semantic ||
- (source.qualityLabel !== 'weak' && source.semantic.relevance >= 0.45) ||
- source.semantic.lastUsedInCase === true);
+ const seen = new Set();
+ const retainedSources = sources.filter((source) => {
+ const keep = !source.semantic ||
+ (source.qualityLabel !== 'weak' && source.semantic.relevance >= 0.45) ||
+ source.semantic.lastUsedInCase === true;
+ if (!keep) return false;
+ // The same page twice in one answer is saved once.
+ const key = normalizeSourceUrl(source.url) ?? source.url;
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ });
if (retainedSources.length === 0) return;
- const session = useSessionStore.getState();
for (const source of retainedSources) {
+ const session = useSessionStore.getState();
+ const metadata = {
+ url: source.url,
+ ...(source.sourceType !== undefined ? { sourceType: source.sourceType } : {}),
+ ...(source.qualityScore !== undefined ? { qualityScore: source.qualityScore } : {}),
+ ...(source.qualityLabel !== undefined ? { qualityLabel: source.qualityLabel } : {}),
+ ...(source.query !== undefined ? { query: source.query } : {}),
+ ...(source.semantic !== undefined ? { semantic: source.semantic } : {}),
+ capturedVia,
+ };
+ const key = normalizeSourceUrl(source.url);
+ const existing = key
+ ? session.sourceLibrary.find((saved) => saved.debateId === targetDebateId && savedSourceUrl(saved) === key)
+ : undefined;
try {
+ if (existing) {
+ // Already in this debate's Library: refresh what research learned about
+ // it, and keep the student's title, folder and on/off choice.
+ await session.updateSource(existing.id, {
+ metadata: { ...existing.metadata, ...metadata },
+ ...(!existing.content.trim() && source.content ? { content: source.content } : {}),
+ });
+ continue;
+ }
await session.addSource({
title: source.title,
content: source.content ?? '',
@@ -137,15 +209,7 @@ export async function autosaveDiscoveredSources(sources: DiscoveredSource[], cap
folder: 'Auto-saved',
debateId: targetDebateId,
isActive: source.qualityLabel === 'strong' || source.qualityLabel === 'moderate',
- metadata: {
- url: source.url,
- ...(source.sourceType !== undefined ? { sourceType: source.sourceType } : {}),
- ...(source.qualityScore !== undefined ? { qualityScore: source.qualityScore } : {}),
- ...(source.qualityLabel !== undefined ? { qualityLabel: source.qualityLabel } : {}),
- ...(source.query !== undefined ? { query: source.query } : {}),
- ...(source.semantic !== undefined ? { semantic: source.semantic } : {}),
- capturedVia,
- },
+ metadata,
});
} catch {
toast({
From 00e10ac4ae498c1cb9857f7a5e902a8a2c8d859a Mon Sep 17 00:00:00 2001
From: Adam
Date: Thu, 22 Oct 2026 11:47:12 +0000
Subject: [PATCH 04/12] Let Library titles wrap on phones and enlarge row
controls (M5)
Below the sm breakpoint a source title takes two lines instead of one
truncated line, and the type badge (already shown by the row icon) is hidden
to give it room. On touch screens the delete button is 44px and the whole
44px square around the on/off switch toggles it.
Co-Authored-By: Claude Opus 5.5
---
src/pages/LibraryPage.tsx | 17 ++++++++++-------
1 file changed, 10 insertions(+), 7 deletions(-)
diff --git a/src/pages/LibraryPage.tsx b/src/pages/LibraryPage.tsx
index 27357364d..01023fef5 100644
--- a/src/pages/LibraryPage.tsx
+++ b/src/pages/LibraryPage.tsx
@@ -74,14 +74,15 @@ function SourceRow({
{source.type === 'url' ? : }
-
+ {/* On a phone the title gets two lines of its own; one truncated line left a few letters. */}
+
{
event.stopPropagation();
onOpen(source);
}}
- className="min-w-0 truncate rounded-sm text-left text-sm font-medium text-foreground transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+ className="line-clamp-2 min-w-0 break-words rounded-sm text-left text-sm font-medium text-foreground transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:line-clamp-1"
>
{source.title}
@@ -91,13 +92,14 @@ function SourceRow({
target="_blank"
rel="noopener noreferrer"
onClick={(event) => event.stopPropagation()}
- className="shrink-0 rounded-sm text-muted-foreground transition-colors hover:text-primary"
+ className="mt-0.5 shrink-0 rounded-sm text-muted-foreground transition-colors hover:text-primary sm:mt-0"
aria-label={t('libraryPage.openOrigin', { title: source.title, defaultValue: 'Open {{title}} in a new tab' })}
>
)}
-
+ {/* The row icon already shows the type; on a phone the title needs the room. */}
+
{t(`libraryPage.${source.type}Source`, source.type.toUpperCase())}
@@ -135,7 +137,7 @@ function SourceRow({
{
event.stopPropagation();
onRemove(source);
@@ -144,13 +146,14 @@ function SourceRow({
>
-
event.stopPropagation()}>
+ {/* A label, so a tap anywhere in the 44px square flips the switch, not only the switch itself. */}
+ event.stopPropagation()}>
onToggle(source.id)}
aria-label={t('libraryPage.toggleSourceLabel', { title: source.title })}
/>
-
+
From 8c4d1ea4c1a86c5b589676c1a139cccfce85d9e1 Mon Sep 17 00:00:00 2001
From: Adam
Date: Thu, 22 Oct 2026 11:48:18 +0000
Subject: [PATCH 05/12] Stop telling every account it signed in with Google
(L1)
Settings no longer hard-codes "Signed in with Google". Under the email it
shows how the account signs in, from what Auth recorded: Google, email code,
both, or demo code. The description keeps only the API key sentence.
Co-Authored-By: Claude Opus 5.5
---
src/locales/en.json | 6 +++++-
src/locales/es.json | 6 +++++-
src/locales/hu.json | 6 +++++-
src/pages/SettingsPage.tsx | 17 +++++++++++++++++
4 files changed, 32 insertions(+), 3 deletions(-)
diff --git a/src/locales/en.json b/src/locales/en.json
index bf0b2bcdc..695bb3705 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -456,9 +456,13 @@
"accessExpired": "Your access has expired. Upgrade to continue using AI tools.",
"globalSettings": "Global Settings",
"account": "Account",
- "accountDesc": "Signed in with Google. API keys are managed securely on the server.",
+ "accountDesc": "API keys are managed securely on the server.",
"signedInAs": "Signed in as",
"unknownUser": "Unknown user",
+ "signInGoogle": "Sign-in: Google",
+ "signInEmail": "Sign-in: email code",
+ "signInGoogleOrEmail": "Sign-in: Google or email code",
+ "signInDemo": "Sign-in: demo code",
"signOut": "Sign out",
"signOutFailed": "Could not sign out. Please try again.",
"aiModels": "AI Models",
diff --git a/src/locales/es.json b/src/locales/es.json
index 1fdef627f..288655ce3 100644
--- a/src/locales/es.json
+++ b/src/locales/es.json
@@ -456,9 +456,13 @@
"accessExpired": "Tu acceso ha caducado. Mejora el plan para seguir usando las herramientas de IA.",
"globalSettings": "Ajustes globales",
"account": "Cuenta",
- "accountDesc": "Sesión iniciada con Google. Las claves API se gestionan de forma segura en el servidor.",
+ "accountDesc": "Las claves API se gestionan de forma segura en el servidor.",
"signedInAs": "Sesión iniciada como",
"unknownUser": "Usuario desconocido",
+ "signInGoogle": "Acceso: Google",
+ "signInEmail": "Acceso: código por correo",
+ "signInGoogleOrEmail": "Acceso: Google o código por correo",
+ "signInDemo": "Acceso: código de demostración",
"signOut": "Cerrar sesión",
"signOutFailed": "No se pudo cerrar la sesión. Inténtalo de nuevo.",
"aiModels": "Modelos de IA",
diff --git a/src/locales/hu.json b/src/locales/hu.json
index eaf1bfcec..4ad700808 100644
--- a/src/locales/hu.json
+++ b/src/locales/hu.json
@@ -456,9 +456,13 @@
"accessExpired": "A hozzáférésed lejárt. Válts magasabb csomagra az MI-eszközök további használatához.",
"globalSettings": "Globális beállítások",
"account": "Fiók",
- "accountDesc": "Google-fiókkal vagy bejelentkezve. Az API-kulcsokat biztonságosan a szerver kezeli.",
+ "accountDesc": "Az API-kulcsokat biztonságosan a szerver kezeli.",
"signedInAs": "Bejelentkezve mint",
"unknownUser": "Ismeretlen felhasználó",
+ "signInGoogle": "Bejelentkezés: Google",
+ "signInEmail": "Bejelentkezés: e-mailes kód",
+ "signInGoogleOrEmail": "Bejelentkezés: Google vagy e-mailes kód",
+ "signInDemo": "Bejelentkezés: demókód",
"signOut": "Kijelentkezés",
"signOutFailed": "Nem sikerült kijelentkezni. Próbáld újra.",
"aiModels": "MI modellek",
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx
index 1e25a3ce1..5ddf2a2df 100644
--- a/src/pages/SettingsPage.tsx
+++ b/src/pages/SettingsPage.tsx
@@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Settings, Lock, Trash2, Crown, CreditCard, Zap, Shield, Download, UserX } from 'lucide-react';
+import type { User } from '@supabase/supabase-js';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
@@ -48,6 +49,20 @@ const tierLabels: Record = {
expired: { labelKey: 'settings.tierExpired', color: 'border-destructive/30 bg-destructive/10 text-destructive' },
};
+/** How this account signs in, from what Auth recorded for it (not only this session). */
+function signInMethodKey(user: User | null): string | null {
+ const meta = user?.app_metadata;
+ if (!meta) return null;
+ if (typeof meta.demo_access_code_id === 'string') return 'settings.signInDemo';
+ const providers: unknown[] = Array.isArray(meta.providers) ? meta.providers : [meta.provider];
+ const google = providers.includes('google');
+ const email = providers.includes('email');
+ if (google && email) return 'settings.signInGoogleOrEmail';
+ if (google) return 'settings.signInGoogle';
+ if (email) return 'settings.signInEmail';
+ return null;
+}
+
/** The next midnight UTC, when the daily AI requests refresh, on the reader's clock. */
function dailyResetTime(locale: string): string {
const now = new Date();
@@ -62,6 +77,7 @@ export default function SettingsPage() {
const { user, signOut } = useAuth();
const { toast } = useToast();
const { t, i18n } = useTranslation();
+ const signInMethod = signInMethodKey(user);
const subscription = useSubscription();
const [pricingOpen, setPricingOpen] = useState(false);
const [portalLoading, setPortalLoading] = useState(false);
@@ -367,6 +383,7 @@ export default function SettingsPage() {
{t('settings.signedInAs')}
{user?.email || t('settings.unknownUser')}
+ {signInMethod &&
{t(signInMethod)}
}
{signingOut ? t('settings.signingOut', 'Signing out…') : t('settings.signOut')}
From b283811af22ab024a9d89f7542dc138573b366a5 Mon Sep 17 00:00:00 2001
From: Adam
Date: Thu, 22 Oct 2026 11:50:45 +0000
Subject: [PATCH 06/12] Name flowchart edges by their nodes and translate the
keyboard help (L3)
Edges are announced as "From Thesis to Argument 1" (computed for the canvas
only, never saved) instead of "Edge from thesis to c1". React Flow 11 has
no prop for its keyboard help text, so a small component puts the EN/ES/HU
help into the two description elements it renders.
Co-Authored-By: Claude Opus 5.5
---
src/locales/en.json | 3 +++
src/locales/es.json | 3 +++
src/locales/hu.json | 3 +++
src/pages/FlowchartPage.tsx | 32 +++++++++++++++++++++++++++++++-
4 files changed, 40 insertions(+), 1 deletion(-)
diff --git a/src/locales/en.json b/src/locales/en.json
index 695bb3705..1862c2156 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -1541,6 +1541,9 @@
"fitView": "Fit view",
"toggleInteractivity": "Lock or unlock the canvas",
"miniMap": "Flowchart overview",
+ "edgeAriaLabel": "From {{source}} to {{target}}",
+ "a11yNodeHelp": "Press Enter or Space to select a node. You can then use the arrow keys to move it. Press Delete to remove it and Escape to cancel.",
+ "a11yEdgeHelp": "Press Enter or Space to select a link. You can then press Delete to remove it or Escape to cancel.",
"deletedNamed": "Deleted {{title}}",
"deleteNamed": "Delete {{title}}",
"restoreFailed": "Could not restore the flowchart",
diff --git a/src/locales/es.json b/src/locales/es.json
index 288655ce3..43527a667 100644
--- a/src/locales/es.json
+++ b/src/locales/es.json
@@ -1541,6 +1541,9 @@
"fitView": "Ajustar a la vista",
"toggleInteractivity": "Bloquear o desbloquear el lienzo",
"miniMap": "Vista general del diagrama",
+ "edgeAriaLabel": "De {{source}} a {{target}}",
+ "a11yNodeHelp": "Pulsa Intro o Espacio para seleccionar un nodo. Después puedes moverlo con las flechas. Pulsa Suprimir para eliminarlo y Escape para cancelar.",
+ "a11yEdgeHelp": "Pulsa Intro o Espacio para seleccionar un enlace. Después pulsa Suprimir para eliminarlo o Escape para cancelar.",
"deletedNamed": "Se eliminó {{title}}",
"deleteNamed": "Eliminar {{title}}",
"restoreFailed": "No se pudo restaurar el diagrama",
diff --git a/src/locales/hu.json b/src/locales/hu.json
index 4ad700808..2c18d43a9 100644
--- a/src/locales/hu.json
+++ b/src/locales/hu.json
@@ -1541,6 +1541,9 @@
"fitView": "Teljes nézet",
"toggleInteractivity": "Vászon zárolása vagy feloldása",
"miniMap": "Folyamatábra áttekintése",
+ "edgeAriaLabel": "Kapcsolat: {{source}} → {{target}}",
+ "a11yNodeHelp": "Csomópont kijelöléséhez nyomd meg az Entert vagy a szóközt. Utána a nyílbillentyűkkel mozgathatod. A Delete törli, az Escape megszakítja.",
+ "a11yEdgeHelp": "Kapcsolat kijelöléséhez nyomd meg az Entert vagy a szóközt. Utána a Delete törli, az Escape megszakítja.",
"deletedNamed": "Törölve: {{title}}",
"deleteNamed": "{{title}} törlése",
"restoreFailed": "Nem sikerült visszaállítani a folyamatábrát",
diff --git a/src/pages/FlowchartPage.tsx b/src/pages/FlowchartPage.tsx
index 97239ef82..d33da9586 100644
--- a/src/pages/FlowchartPage.tsx
+++ b/src/pages/FlowchartPage.tsx
@@ -105,6 +105,23 @@ const strengthTranslationKeys = {
weak: 'flowchart.strengthWeak',
};
+// React Flow 11 writes its keyboard help for nodes and edges in English, with no
+// prop to change it (v12's ariaLabelConfig has one). That text is static, so
+// React never writes it again after mount; the translated help takes its place.
+function LocalizedA11yDescriptions(): null {
+ const { t } = useTranslation();
+ const rfId = useStore((state) => state.rfId);
+ useEffect(() => {
+ const replace = (id: string, text: string) => {
+ const element = document.getElementById(id);
+ if (element && element.textContent !== text) element.textContent = text;
+ };
+ replace(`react-flow__node-desc-${rfId}`, t('flowchart.a11yNodeHelp'));
+ replace(`react-flow__edge-desc-${rfId}`, t('flowchart.a11yEdgeHelp'));
+ }, [rfId, t]);
+ return null;
+}
+
// React Flow's own control buttons have fixed English names, so the canvas
// renders the same four with translated ones.
function LocalizedControls() {
@@ -269,6 +286,18 @@ export default function FlowchartPage() {
const [nodes, setNodes, onNodesChange] = useNodesState(createInitialNodes(t));
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
+ // Screen readers name an edge by the nodes it joins, not by internal ids.
+ // Only the canvas gets these; saved charts keep plain edges.
+ // Keyed on ids and titles, so dragging a node does not rebuild every edge.
+ const nodeTitlesKey = JSON.stringify(nodes.map((node) => [node.id, (node.data as FlowNodeData | undefined)?.label ?? '']));
+ const nodeTitles = useMemo(() => new Map(JSON.parse(nodeTitlesKey) as Array<[string, string]>), [nodeTitlesKey]);
+ const labelledEdges = useMemo(() => edges.map((edge) => ({
+ ...edge,
+ ariaLabel: t('flowchart.edgeAriaLabel', {
+ source: nodeTitles.get(edge.source) || edge.source,
+ target: nodeTitles.get(edge.target) || edge.target,
+ }),
+ })), [edges, nodeTitles, t]);
// Save/Load dialog states
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
@@ -792,7 +821,7 @@ export default function FlowchartPage() {
zoomOnScroll={false}
preventScrolling={false}
nodes={nodes}
- edges={edges}
+ edges={labelledEdges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
@@ -804,6 +833,7 @@ export default function FlowchartPage() {
attributionPosition="bottom-left"
>
+
{
From 655dcc0230c2fbf08fe76e2804a5a64357d02337 Mon Sep 17 00:00:00 2001
From: Adam
Date: Thu, 22 Oct 2026 11:53:34 +0000
Subject: [PATCH 07/12] Give the Attack switches and the Ask message box
accessible names (L4)
Co-Authored-By: Claude Opus 5.5
---
src/components/features/ask/AskChat.tsx | 1 +
src/components/features/attack/AttackForm.tsx | 12 ++++++++----
src/locales/en.json | 1 +
src/locales/es.json | 1 +
src/locales/hu.json | 1 +
5 files changed, 12 insertions(+), 4 deletions(-)
diff --git a/src/components/features/ask/AskChat.tsx b/src/components/features/ask/AskChat.tsx
index 8c4816cb6..c0a683d9e 100644
--- a/src/components/features/ask/AskChat.tsx
+++ b/src/components/features/ask/AskChat.tsx
@@ -778,6 +778,7 @@ export function AskChat({ messages, streamingMessage, isLoading, error, onDismis
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={isListening ? t('askChat.placeholderListening') : t('askChat.placeholder')}
+ aria-label={t('askChat.inputLabel')}
className="flex-1"
/>
{isLoading && onStop ? (
diff --git a/src/components/features/attack/AttackForm.tsx b/src/components/features/attack/AttackForm.tsx
index c14d08d26..ad0a8f6bd 100644
--- a/src/components/features/attack/AttackForm.tsx
+++ b/src/components/features/attack/AttackForm.tsx
@@ -162,20 +162,24 @@ export function AttackForm({ onSubmit, isLoading, importedCase, debateContext =
-
{t('attackForm.detectFallacies')}
-
{t('attackForm.detectFallaciesDesc')}
+
{t('attackForm.detectFallacies')}
+
{t('attackForm.detectFallaciesDesc')}
setValue('detectFallacies', checked)}
/>
-
{t('attackForm.analyzeWeighing')}
-
{t('attackForm.analyzeWeighingDesc')}
+
{t('attackForm.analyzeWeighing')}
+
{t('attackForm.analyzeWeighingDesc')}
setValue('analyzeWeighing', checked)}
/>
diff --git a/src/locales/en.json b/src/locales/en.json
index 1862c2156..3c16f559b 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -1305,6 +1305,7 @@
"pros": "Pros",
"cons": "Cons",
"placeholder": "Ask your debate coach...",
+ "inputLabel": "Message to your debate coach",
"placeholderListening": "Listening...",
"saveToLibrary": "Save to Library",
"saveMessage": "Save message to library",
diff --git a/src/locales/es.json b/src/locales/es.json
index 43527a667..22eacd5f3 100644
--- a/src/locales/es.json
+++ b/src/locales/es.json
@@ -1305,6 +1305,7 @@
"pros": "Pros",
"cons": "Contras",
"placeholder": "Pregunta a tu coach de debate...",
+ "inputLabel": "Mensaje para tu coach de debate",
"placeholderListening": "Escuchando...",
"saveToLibrary": "Guardar en biblioteca",
"saveMessage": "Guardar mensaje en biblioteca",
diff --git a/src/locales/hu.json b/src/locales/hu.json
index 2c18d43a9..f6c4613a2 100644
--- a/src/locales/hu.json
+++ b/src/locales/hu.json
@@ -1305,6 +1305,7 @@
"pros": "Mellette",
"cons": "Ellene",
"placeholder": "Kérdezd a vitaedződ...",
+ "inputLabel": "Üzenet a vitaedződnek",
"placeholderListening": "Hallgatás...",
"saveToLibrary": "Mentés a könyvtárba",
"saveMessage": "Üzenet mentése a könyvtárba",
From dfd5371086b25db6a45fd22ebdfd4726713f415f Mon Sep 17 00:00:00 2001
From: Adam
Date: Thu, 22 Oct 2026 11:53:35 +0000
Subject: [PATCH 08/12] Title each app page and keep private pages out of
search (L4, L5)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
/login, /app/* and /share/* (including a share link that no longer works) get
a page title from the existing route labels, such as "Generate · DebateLab",
and robots "noindex, nofollow" while on screen, matching the X-Robots-Tag
vercel.json already sends. /expocit/sources is left as it is.
Co-Authored-By: Claude Opus 5.5
---
src/App.tsx | 2 ++
src/lib/seo/use-page-head.ts | 24 +++++++++++++++++++++++-
2 files changed, 25 insertions(+), 1 deletion(-)
diff --git a/src/App.tsx b/src/App.tsx
index f2ac09a73..50896a629 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -17,6 +17,7 @@ import { StagingBanner } from "@/components/StagingBanner";
import { captureException, captureProductEvent, toTelemetrySafeError } from "@/lib/observability";
import { routeLoaders, type RouteLoader } from "@/lib/route-modules";
import type { PrerenderedRoute } from "@/lib/seo/pages";
+import { usePrivateRouteHead } from "@/lib/seo/use-page-head";
import { RouteSkeleton, type RouteSkeletonVariant } from "@/components/loading/RouteSkeleton";
import { useTranslation } from "react-i18next";
import { COLLABORATION_URL } from "@/lib/workspace/collaboration-config";
@@ -212,6 +213,7 @@ function RouteContent({ children }: { children: ReactNode }) {
const fallback = getRouteFallback(location.pathname);
const label = fallback.labelKey ? t(fallback.labelKey, fallback.label) : fallback.label;
const statusText = t("common.openingPage", { page: label });
+ usePrivateRouteHead(location.pathname, label);
return (
diff --git a/src/lib/seo/use-page-head.ts b/src/lib/seo/use-page-head.ts
index 9ba6d8171..e9aeb703d 100644
--- a/src/lib/seo/use-page-head.ts
+++ b/src/lib/seo/use-page-head.ts
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
-import { PAGE_HEADS, SHELL_HEAD, SITE_ORIGIN, type PrerenderedRoute } from './pages';
+import { BRAND, PAGE_HEADS, SHELL_HEAD, SITE_ORIGIN, type PrerenderedRoute } from './pages';
function setMeta(name: string, content: string) {
let meta = document.head.querySelector(`meta[name="${name}"]`);
@@ -44,3 +44,25 @@ export function usePageHead(route: PrerenderedRoute) {
};
}, [route]);
}
+
+/** Sign-in, the signed-in app and share links: never indexed (vercel.json sends the same X-Robots-Tag). */
+const PRIVATE_ROUTE = /^\/(login|app|share)(\/|$)/;
+
+/**
+ * Titles each private page " · DebateLab" and marks it noindex while it
+ * is on screen. Every other route keeps the shell's head or sets its own.
+ */
+export function usePrivateRouteHead(pathname: string, label: string) {
+ const isPrivate = PRIVATE_ROUTE.test(pathname);
+ useEffect(() => {
+ if (!isPrivate) return;
+ // "Sign in to DebateLab" already names the brand.
+ document.title = label.includes(BRAND) ? label : `${label} · ${BRAND}`;
+ setMeta('robots', 'noindex, nofollow');
+ setCanonical(undefined);
+ return () => {
+ document.title = SHELL_HEAD.title;
+ setMeta('robots', SHELL_HEAD.robots);
+ };
+ }, [isPrivate, label]);
+}
From e060ba6116fac23efe01b609aabebcbe6080191c Mon Sep 17 00:00:00 2001
From: Adam
Date: Thu, 22 Oct 2026 11:55:09 +0000
Subject: [PATCH 09/12] Document session expiry, source dedupe and the usage
estimate; follow the Ask label in replays
architecture.md describes the revoked-session check (H1), one Library row per
page per debate (M3), and what the remaining-requests estimate can and cannot
know (M1, M2, L2). The Ask message box's accessible name is now its label
(L4), so the two recorded agentic replays target that name.
Co-Authored-By: Claude Opus 5.5
---
...8da6cfdc7035b87036fd9cc0ac6dbdd9fc8d128176a57f60dfd.json | 6 +++---
...346e9ad878bdd394cbf70ccb746bdcfcc35af4c0a7d2cf6795e.json | 4 ++--
docs/architecture.md | 6 +++++-
3 files changed, 10 insertions(+), 6 deletions(-)
diff --git a/agentic/replay/4b19b5a96c8088da6cfdc7035b87036fd9cc0ac6dbdd9fc8d128176a57f60dfd.json b/agentic/replay/4b19b5a96c8088da6cfdc7035b87036fd9cc0ac6dbdd9fc8d128176a57f60dfd.json
index 57c8d554e..81d809bbc 100644
--- a/agentic/replay/4b19b5a96c8088da6cfdc7035b87036fd9cc0ac6dbdd9fc8d128176a57f60dfd.json
+++ b/agentic/replay/4b19b5a96c8088da6cfdc7035b87036fd9cc0ac6dbdd9fc8d128176a57f60dfd.json
@@ -5,10 +5,10 @@
"actions": [
{
"name": "type",
- "summary": "type \"How do I weigh access against cost in a…\" into textbox \"Ask your debate coach...\"",
+ "summary": "type \"How do I weigh access against cost in a…\" into textbox \"Message to your debate coach\"",
"target": {
"role": "textbox",
- "name": "Ask your debate coach...",
+ "name": "Message to your debate coach",
"placeholder": "Ask your debate coach..."
},
"value": "How do I weigh access against cost in a rebuttal?"
@@ -64,7 +64,7 @@
},
{
"role": "textbox",
- "name": "Ask your debate coach...",
+ "name": "Message to your debate coach",
"placeholder": "Ask your debate coach..."
}
],
diff --git a/agentic/replay/d636e78bd57e1346e9ad878bdd394cbf70ccb746bdcfcc35af4c0a7d2cf6795e.json b/agentic/replay/d636e78bd57e1346e9ad878bdd394cbf70ccb746bdcfcc35af4c0a7d2cf6795e.json
index ea2c6442a..4b5b04e69 100644
--- a/agentic/replay/d636e78bd57e1346e9ad878bdd394cbf70ccb746bdcfcc35af4c0a7d2cf6795e.json
+++ b/agentic/replay/d636e78bd57e1346e9ad878bdd394cbf70ccb746bdcfcc35af4c0a7d2cf6795e.json
@@ -5,10 +5,10 @@
"actions": [
{
"name": "type",
- "summary": "type \"How do I weigh access against cost in a…\" into textbox \"Ask your debate coach...\"",
+ "summary": "type \"How do I weigh access against cost in a…\" into textbox \"Message to your debate coach\"",
"target": {
"role": "textbox",
- "name": "Ask your debate coach...",
+ "name": "Message to your debate coach",
"placeholder": "Ask your debate coach..."
},
"value": "How do I weigh access against cost in a rebuttal?"
diff --git a/docs/architecture.md b/docs/architecture.md
index 3e6fe3670..698d264ff 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -39,6 +39,10 @@ The repository is an npm workspace. The web app is the root package and still li
[`src/lib/supabase/AuthProvider.tsx`](../src/lib/supabase/AuthProvider.tsx) coordinates store hydration and reset across authentication changes. Store code is not the database schema source of truth; migrations are.
+Discovered-source autosave in [`src/lib/ai/sources.ts`](../src/lib/ai/sources.ts) keeps one Library row per page per debate. It matches on a normalized URL (host without `www.`, no scheme, fragment, tracking parameters or trailing slash) and refreshes the existing row's research metadata instead of inserting a second one. Batches run one at a time within a tab; two tabs can still race, which only a unique index would stop.
+
+[`src/hooks/use-subscription.ts`](../src/hooks/use-subscription.ts) estimates today's remaining AI requests from the account's `ai_requests` rows (refused calls and `jev_*` helper rows excluded) and its own `rate_limits` override. Global `rate_limits` rows are server-only, so the estimate only warns (Sparring setup) and informs (Settings); it is null, never blocking, when any read fails. The `ai` function's 402 stays the decision, and every tool shows it with the same daily-limit dialog.
+
## Language behavior
[`packages/core/src/languages.ts`](../packages/core/src/languages.ts) is the shared catalog for English, Spanish, and Hungarian language codes, labels, and Intl locales. [`src/i18n.ts`](../src/i18n.ts) owns the locale resources and applies the selected interface language to i18next, document metadata, and ``. Logged-out startup preserves local language preferences; authenticated hydration accepts only catalog languages.
@@ -49,7 +53,7 @@ Interface language and AI-output language are separate settings. Interface langu
[`src/lib/supabase/env.ts`](../src/lib/supabase/env.ts) resolves production or staging client configuration. Public URLs and anon keys are browser configuration. Do not copy their values into docs. Admins switch projects from Settings; see [operations.md](operations.md#switching-the-frontend-to-staging).
-[`src/lib/supabase/client.ts`](../src/lib/supabase/client.ts) creates the client with persistent sessions, automatic refresh, PKCE, and environment-specific storage keys.
+[`src/lib/supabase/client.ts`](../src/lib/supabase/client.ts) creates the client with persistent sessions, automatic refresh, PKCE, and environment-specific storage keys. Its fetch notices when a signed-in request is refused (401 from REST or Functions, 401 or 403 from Auth's user lookup). [`session-expiry.ts`](../src/lib/supabase/session-expiry.ts) then forces a token refresh outside that request: if Auth refuses the refresh too, the session was revoked, so the browser signs out locally and the login page says the session expired. A network failure, 5xx or rate limit keeps the session.
Client-side environment selection is a developer convenience, not authorization. Edge Functions and database policies must enforce access independently.
From 332a2ccef87067d36511a34c88c02e54de3f3960 Mon Sep 17 00:00:00 2001
From: Adam
Date: Thu, 22 Oct 2026 12:00:14 +0000
Subject: [PATCH 10/12] Add verify recipes for a revoked session and for
running out of daily requests
Co-Authored-By: Claude Opus 5.5
---
.../features/auth-demo-login.md | 3 ++
.../verify-debatelab/features/practice.md | 5 ++
.../verify-debatelab/recipes/auth.spec.ts | 29 +++++++++++
.../verify-debatelab/recipes/practice.spec.ts | 51 +++++++++++++++++++
4 files changed, 88 insertions(+)
diff --git a/.claude/skills/verify-debatelab/features/auth-demo-login.md b/.claude/skills/verify-debatelab/features/auth-demo-login.md
index e0d727f56..e5ce02605 100644
--- a/.claude/skills/verify-debatelab/features/auth-demo-login.md
+++ b/.claude/skills/verify-debatelab/features/auth-demo-login.md
@@ -9,6 +9,7 @@ A visitor signs in with a demo access code on the Login page. The `demo-code-log
- `auth-reject` shows the alert `Demo code not found.` for an unknown code and stays on `/login`.
- `auth-signout` signs out from Settings, after which `/app/generate` redirects to `/login`.
- `auth-refresh` refreshes an expired access token before an AI request.
+- `auth-session-expired` signs out a session the server revoked: the stored tokens are refused, so the app goes to `/login` and shows `Your session expired. Sign in again.` once (`recipes/auth.spec.ts`).
## How to get to it (user POV)
@@ -26,9 +27,11 @@ Preconditions:
- **Persist.** Reload the page. The URL stays `/app`. On `/app/settings`, the page shows `user.email`.
- **Reject.** On a signed-out page, submit `DEMO-ZZZZ-ZZZZ`. The element with role `alert` has the text `Demo code not found.`, and the URL still matches `/login`.
- **Sign out.** On `/app/settings`, click the button named `Sign out`, then go to `/app/generate`. The URL matches `/login`.
+- **Revoked session.** Signed in on `/app`, replace `access_token` in the `debate-auth-local` localStorage entry with a JWT the server refuses, `refresh_token` with `x`, and `expires_at` with now + 3000 s, then reload. Within a few seconds the URL matches `/login` and a `status` element says `Your session expired. Sign in again.`. A second reload no longer shows it.
## Gotchas
- A first sign-in on a new account starts the onboarding tour on `/app`. Its first step (`Welcome to DebateLab!`, `Start your first debate here.`) offers `Start debate`. The fixtures pre-seed `debate-trainer-onboarding` to skip it. Interactive runs click `Skip Tour` (`[data-test-id="button-skip"]`). The tour itself is covered in [current-debate](./current-debate.md).
- Sessions are stored under the `debate-auth-local` localStorage key in local-preview mode. Don't reuse stub-session helpers (`e2e/helpers/auth.ts`). They fake auth and prove rendering only.
- Google sign-in is not wired to the local stack. Only demo codes work here.
+- The revoked-session check forces one token refresh before it signs out. An Auth outage (network error, 5xx, 429) keeps the session, and that path is covered by `src/test/auth/session-expiry.test.ts`, not by a recipe.
diff --git a/.claude/skills/verify-debatelab/features/practice.md b/.claude/skills/verify-debatelab/features/practice.md
index 3e93dabea..484933dd3 100644
--- a/.claude/skills/verify-debatelab/features/practice.md
+++ b/.claude/skills/verify-debatelab/features/practice.md
@@ -9,6 +9,9 @@ Live practice modes. Sparring runs a multi-speech round against an AI opponent a
- `cross-turn` answers a user question with an AI response and attaches a Jev `Direct answer` signal.
- `cross-debrief` ends the session and shows the debrief. A `crossfires` row is filed in the debate for its resolution.
- `cross-ai-questions` reverses the roles: the AI asks (`mode-ai-questions-user`), the user answers, and the AI follows up.
+- `cross-daily-limit` with no AI requests left, the first AI turn is refused: the daily-limit dialog opens and the page stays in setup with no clock and no `end-crossfire`.
+- `cross-end-early` ending a round before any exchange returns to setup with no error.
+- `spar-daily-limit` Sparring setup warns (test id `sparring-request-shortfall`) when today's remaining requests are fewer than the round needs.
## How to get to it (user POV)
@@ -27,10 +30,12 @@ Preconditions:
- **Start Crossfire.** On `/app/crossfire`, fill test id `crossfire-resolution` and `crossfire-argument`. Click `mode-question-ai` (or `mode-ai-questions-user`), then `start-crossfire`, then `chooseForMotion(page, "create")`.
- **Crossfire turn.** Fill `crossfire-input` and click `submit-turn`. The text `FAKE-PROVIDER crossfire answer` and `Direct answer` appear. `providerCall("crossfire")` and `providerCall("jev")` both fired.
- **Debrief.** Click `end-crossfire`. Test id `crossfire-debrief` is visible. `crossfires` for the user equals `[{title: , debates: {motion: }}]`.
+- **Out of requests.** Through `admin`, set `user_settings.daily_limit_override` to N, upsert today's `daily_usage_counters` row (`quota_key: "ai"`, `request_count: N`; what the `ai` function checks), and insert N `ai_requests` rows (what the browser counts). Start Crossfire in `mode-ai-questions-user`: a `dialog` containing `N of N` opens and `start-crossfire` is back. On `/app/sparring`, `sparring-request-shortfall` says `You have no AI requests left today`.
## Gotchas
- Opponent speeches carry a server-signed integrity token. Scoring fails if the client drops it, so a verdict proves the round-trip.
- Jev is faked with a fixed confident answer. `Direct answer` proves the wiring, not the quality of the judgement.
- Sparring AI calls happen once per opponent turn. Keep the per-step wait near 30 s rather than one long wait for the verdict.
+- The browser's remaining-requests count leaves out `rate_limited` and `jev_*` rows and cannot see global `rate_limits` rows, so it warns and never blocks. The `ai` function's 402 decides.
- In `mode-ai-questions-user` the opening question is requested as soon as the round starts, after the motion dialog. Create the `aiResponse(page, "crossfire", "next-turn")` promise before clicking `start-crossfire`.
diff --git a/.claude/skills/verify-debatelab/recipes/auth.spec.ts b/.claude/skills/verify-debatelab/recipes/auth.spec.ts
index ac1dc992e..8a2b551ad 100644
--- a/.claude/skills/verify-debatelab/recipes/auth.spec.ts
+++ b/.claude/skills/verify-debatelab/recipes/auth.spec.ts
@@ -37,3 +37,32 @@ test("auth-email-otp: an emailed one-time code signs an existing account in", as
await expect(page).toHaveURL(/\/app/);
await evidence.shot("02-signed-in-by-email");
});
+
+test("auth-session-expired: a session the server revoked signs out to the login page with a message", async ({ signedIn: page, evidence }) => {
+ await page.goto("/app");
+ await expect(page).toHaveURL(/\/app$/);
+ // Like an account deleted in another tab or a password reset: the stored
+ // tokens are still there, but the server refuses both of them.
+ await page.evaluate(() => {
+ const key = Object.keys(localStorage).find((name) => /^debate-auth-[a-z]+$/.test(name));
+ if (!key) throw new Error("no stored session");
+ const stored = JSON.parse(localStorage.getItem(key)!) as Record;
+ const header = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" })).replace(/=+$/, "");
+ const claims = btoa(JSON.stringify({ sub: "00000000-0000-4000-8000-000000000000", exp: Math.floor(Date.now() / 1000) + 3000 })).replace(/=+$/, "");
+ localStorage.setItem(key, JSON.stringify({
+ ...stored,
+ access_token: `${header}.${claims}.invalid-signature`,
+ refresh_token: "x",
+ expires_at: Math.floor(Date.now() / 1000) + 3000,
+ }));
+ });
+ await evidence.step("Replaced the stored session with tokens the server refuses");
+ await page.reload();
+ await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
+ await expect(page.getByRole("status").filter({ hasText: "Your session expired. Sign in again." })).toBeVisible();
+ await evidence.shot("01-login-session-expired");
+ await evidence.aria("01-login-session-expired");
+ // The notice is for this sign-out only.
+ await page.reload();
+ await expect(page.getByText("Your session expired. Sign in again.")).toHaveCount(0);
+});
diff --git a/.claude/skills/verify-debatelab/recipes/practice.spec.ts b/.claude/skills/verify-debatelab/recipes/practice.spec.ts
index 921d96625..c24431ef6 100644
--- a/.claude/skills/verify-debatelab/recipes/practice.spec.ts
+++ b/.claude/skills/verify-debatelab/recipes/practice.spec.ts
@@ -109,3 +109,54 @@ test("cross-ai-questions: the AI questions the user, answers are judged, and the
await evidence.shot("02-debrief");
await expectCrossfireSaved(await user.userId());
});
+
+/**
+ * Today's AI requests are used up: the server's counter (what the ai function
+ * checks) and the usage rows (what the browser counts). A refused call and a
+ * Jev helper row are on the list too, and must not count as used.
+ */
+async function useUpDailyRequests(userId: string, limit: number) {
+ await admin.from("user_settings").update({ daily_limit_override: limit }).eq("user_id", userId);
+ const today = new Date().toISOString().slice(0, 10);
+ const counter = await admin.from("daily_usage_counters").upsert({ user_id: userId, usage_date: today, quota_key: "ai", request_count: limit });
+ expect(counter.error).toBeNull();
+ const rows = [
+ ...Array.from({ length: limit }, () => ({ user_id: userId, feature: "generate", provider: "openrouter", model: "fake", status: "success" })),
+ { user_id: userId, feature: "ask", provider: "openrouter", model: "fake", status: "rate_limited" },
+ { user_id: userId, feature: "jev_model_router", provider: "openrouter", model: "fake", status: "success" },
+ ];
+ const usage = await admin.from("ai_requests").insert(rows);
+ expect(usage.error).toBeNull();
+}
+
+test("cross-daily-limit and spar-daily-limit: with no requests left, Crossfire stays in setup and Sparring warns first", async ({ signedIn: page, user, evidence }) => {
+ await useUpDailyRequests(await user.userId(), 2);
+ await evidence.step("Used up the account's 2 daily AI requests");
+
+ await startCrossfire(page, "mode-ai-questions-user");
+ const dialog = page.getByRole("dialog");
+ await expect(dialog).toBeVisible({ timeout: AI_TIMEOUT });
+ await expect(dialog).toContainText("2 of 2");
+ await evidence.shot("01-crossfire-limit-dialog");
+ await evidence.aria("01-crossfire-limit-dialog", dialog);
+ // The round never started: no clock, no End button, the setup kept.
+ await expect(page.getByTestId("end-crossfire")).toHaveCount(0);
+ await page.keyboard.press("Escape");
+ await expect(page.getByTestId("start-crossfire")).toBeVisible();
+ await expect(page.getByTestId("crossfire-resolution")).toHaveValue(resolution);
+ await evidence.shot("02-crossfire-back-in-setup");
+
+ await page.goto("/app/sparring");
+ await expect(page.getByTestId("sparring-request-shortfall")).toContainText("You have no AI requests left today");
+ await evidence.shot("03-sparring-warns");
+ await evidence.aria("03-sparring-warns", page.getByTestId("sparring-request-shortfall"));
+});
+
+test("cross-end-early: ending a round before any exchange returns to setup without an error", async ({ signedIn: page, evidence }) => {
+ await startCrossfire(page, "mode-question-ai");
+ await expect(page.getByTestId("end-crossfire")).toBeVisible();
+ await page.getByTestId("end-crossfire").click();
+ await expect(page.getByTestId("start-crossfire")).toBeVisible();
+ await expect(page.getByText("Complete at least one exchange before ending the session.")).toHaveCount(0);
+ await evidence.shot("01-back-in-setup");
+});
From 75d618056dbbacfbd7af7f42004f0b1b9a95ae0d Mon Sep 17 00:00:00 2001
From: Adam
Date: Thu, 22 Oct 2026 12:52:27 +0000
Subject: [PATCH 11/12] Fix two things the browser checks caught (M5, L4)
M5: on touch screens the Library on/off switch gets its 44px tap area from
a pseudo-element, the same way the Checkbox does. Wrapping it in a label did
not forward taps reliably.
L4: page titles end in "DebateLab" (the shell's title) rather than the
longer brand name from site.json, as the QA report asked.
Co-Authored-By: Claude Opus 5.5
---
src/lib/seo/use-page-head.ts | 8 ++++----
src/pages/LibraryPage.tsx | 7 ++++---
2 files changed, 8 insertions(+), 7 deletions(-)
diff --git a/src/lib/seo/use-page-head.ts b/src/lib/seo/use-page-head.ts
index e9aeb703d..7e6b15740 100644
--- a/src/lib/seo/use-page-head.ts
+++ b/src/lib/seo/use-page-head.ts
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
-import { BRAND, PAGE_HEADS, SHELL_HEAD, SITE_ORIGIN, type PrerenderedRoute } from './pages';
+import { PAGE_HEADS, SHELL_HEAD, SITE_ORIGIN, type PrerenderedRoute } from './pages';
function setMeta(name: string, content: string) {
let meta = document.head.querySelector(`meta[name="${name}"]`);
@@ -49,15 +49,15 @@ export function usePageHead(route: PrerenderedRoute) {
const PRIVATE_ROUTE = /^\/(login|app|share)(\/|$)/;
/**
- * Titles each private page " · DebateLab" and marks it noindex while it
+ * Titles each private page " · DebateLab" (the shell's title) and marks it noindex while it
* is on screen. Every other route keeps the shell's head or sets its own.
*/
export function usePrivateRouteHead(pathname: string, label: string) {
const isPrivate = PRIVATE_ROUTE.test(pathname);
useEffect(() => {
if (!isPrivate) return;
- // "Sign in to DebateLab" already names the brand.
- document.title = label.includes(BRAND) ? label : `${label} · ${BRAND}`;
+ // "Sign in to DebateLab" already names the app.
+ document.title = label.includes(SHELL_HEAD.title) ? label : `${label} · ${SHELL_HEAD.title}`;
setMeta('robots', 'noindex, nofollow');
setCanonical(undefined);
return () => {
diff --git a/src/pages/LibraryPage.tsx b/src/pages/LibraryPage.tsx
index 01023fef5..b586b7c0a 100644
--- a/src/pages/LibraryPage.tsx
+++ b/src/pages/LibraryPage.tsx
@@ -146,14 +146,15 @@ function SourceRow({
>
- {/* A label, so a tap anywhere in the 44px square flips the switch, not only the switch itself. */}
- event.stopPropagation()}>
+ event.stopPropagation()}>
+ {/* On touch the switch's own tap area grows to 44px tall, like the Checkbox's. */}
onToggle(source.id)}
aria-label={t('libraryPage.toggleSourceLabel', { title: source.title })}
/>
-
+
From 6ac5770b4d9609808fbd301bc56f499a30852a84 Mon Sep 17 00:00:00 2001
From: Adam
Date: Thu, 22 Oct 2026 13:10:40 +0000
Subject: [PATCH 12/12] Show daily-limit errors inline in the dialog's words
(L2)
getUserFacingAIError now turns a daily-limit or trial refusal into the limit
dialog's own text, so Crossfire and Sparring stop printing the server's
English message. Sparring's change matches open PR #178 line for line, so
that file merges cleanly; #178's fuller helper in error-display.ts should
replace this one when it lands.
Co-Authored-By: Claude Opus 5.5
---
src/lib/ai/error-display.ts | 8 +++++++-
src/locales/en.json | 1 -
src/locales/es.json | 1 -
src/locales/hu.json | 1 -
src/pages/CrossfirePage.tsx | 3 +--
src/pages/SparringPage.tsx | 14 ++++----------
src/test/crossfire/crossfire-daily-limit.test.tsx | 7 ++++++-
7 files changed, 18 insertions(+), 17 deletions(-)
diff --git a/src/lib/ai/error-display.ts b/src/lib/ai/error-display.ts
index 1289949a1..2e92efc61 100644
--- a/src/lib/ai/error-display.ts
+++ b/src/lib/ai/error-display.ts
@@ -1,5 +1,5 @@
import i18n from '@/i18n';
-import { AIProviderError } from './client';
+import { AIProviderError, isAISubscriptionError } from './client';
const PROVIDER_LABELS: Record = {
openai: 'OpenAI',
@@ -17,6 +17,12 @@ function providerLabel(provider?: string, message = ''): string {
}
export function getUserFacingAIError(error: unknown): string {
+ // Out of today's requests: the limit dialog's own words, not the server's English.
+ if (isAISubscriptionError(error)) {
+ if (error.code === 'trial_expired') return i18n.t('limitModal.trialEndedDesc');
+ return i18n.t('limitModal.limitReachedDesc', { current: error.current ?? 0, limit: error.limit ?? 0 });
+ }
+
if (error instanceof AIProviderError || (error instanceof Error && error.name === 'AIProviderError')) {
const provider = providerLabel((error as AIProviderError).provider, error.message);
const raw = error.message;
diff --git a/src/locales/en.json b/src/locales/en.json
index 3c16f559b..12bdf12c1 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -1357,7 +1357,6 @@
"useFree": "Use low reasoning instead"
},
"dailyLimit": {
- "reachedInline": "You've used today's AI requests. They refresh at midnight UTC.",
"usage": "Today: {{used}} of {{limit}} AI requests used. They refresh at midnight UTC ({{time}} your time).",
"sparringNone": "You have no AI requests left today, so the opponent can't answer. They refresh at midnight UTC.",
"sparringShort": "This round needs {{needed}} AI requests and you have {{remaining}} left today, so it would stop before the judge. They refresh at midnight UTC."
diff --git a/src/locales/es.json b/src/locales/es.json
index 22eacd5f3..f06751e72 100644
--- a/src/locales/es.json
+++ b/src/locales/es.json
@@ -1357,7 +1357,6 @@
"useFree": "Usar razonamiento bajo"
},
"dailyLimit": {
- "reachedInline": "Has usado tus solicitudes de IA de hoy. Se renuevan a medianoche UTC.",
"usage": "Hoy: {{used}} de {{limit}} solicitudes de IA usadas. Se renuevan a medianoche UTC ({{time}} en tu hora).",
"sparringNone": "No te quedan solicitudes de IA hoy, así que el oponente no podrá responder. Se renuevan a medianoche UTC.",
"sparringShort": "Esta ronda necesita {{needed}} solicitudes de IA y hoy te quedan {{remaining}}, así que se detendría antes del juez. Se renuevan a medianoche UTC."
diff --git a/src/locales/hu.json b/src/locales/hu.json
index f6c4613a2..03c540100 100644
--- a/src/locales/hu.json
+++ b/src/locales/hu.json
@@ -1357,7 +1357,6 @@
"useFree": "Alacsony szintű érvelés használata"
},
"dailyLimit": {
- "reachedInline": "Elhasználtad a mai MI-kéréseidet. Éjfélkor (UTC) frissülnek.",
"usage": "Ma: {{used}} / {{limit}} MI-kérés felhasználva. Éjfélkor (UTC) frissülnek (nálad {{time}}).",
"sparringNone": "Mára nem maradt MI-kérésed, így az ellenfél nem tud válaszolni. Éjfélkor (UTC) frissülnek.",
"sparringShort": "Ehhez a körhöz {{needed}} MI-kérés kell, de ma csak {{remaining}} maradt, így a bíró előtt megállna. Éjfélkor (UTC) frissülnek."
diff --git a/src/pages/CrossfirePage.tsx b/src/pages/CrossfirePage.tsx
index 87f821346..85f35d43f 100644
--- a/src/pages/CrossfirePage.tsx
+++ b/src/pages/CrossfirePage.tsx
@@ -23,7 +23,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
-import { crossfire, crossfireSignal, AIResponseError, isAISubscriptionError, isTimeoutError } from '@/lib/ai/client';
+import { crossfire, crossfireSignal, AIResponseError, isTimeoutError } from '@/lib/ai/client';
import { getUserFacingAIError } from '@/lib/ai/error-display';
import {
canEndCrossfireSession,
@@ -185,7 +185,6 @@ function classifyError(error: unknown): {
} {
if (isTimeoutError(error)) return { kind: 'timeout', message: error.message };
if (error instanceof AIResponseError) return { kind: 'response', message: error.message };
- if (isAISubscriptionError(error) && error.code === 'limit_reached') return { kind: 'request', message: 'dailyLimit.reachedInline' };
return { kind: 'request', message: getUserFacingAIError(error) };
}
diff --git a/src/pages/SparringPage.tsx b/src/pages/SparringPage.tsx
index e6199517a..6c5955a0d 100644
--- a/src/pages/SparringPage.tsx
+++ b/src/pages/SparringPage.tsx
@@ -6,6 +6,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
import { useSparringStore } from '@/lib/stores/sparring-store';
import { useSettingsStore } from '@/lib/stores/settings-store';
import { generateSparringTurn, isAISubscriptionError, judgeSparringSession } from '@/lib/ai/client';
+import { getUserFacingAIError } from '@/lib/ai/error-display';
import type { SparringRespondInput, SparringScoreInput, SparringSpeech } from '@/lib/ai/schemas/sparring';
import { SparringSetup, type SparringSetupValues } from '@/components/features/sparring/SparringSetup';
import { SparringRound } from '@/components/features/sparring/SparringRound';
@@ -29,14 +30,11 @@ import { useSubscriptionError } from '@/hooks/use-subscription-error';
import { SubscriptionErrorModals } from '@/components/features/subscription';
function readableError(error: unknown, fallback: string) {
+ if (isAISubscriptionError(error)) return getUserFacingAIError(error);
if (error instanceof Error && error.message.trim()) return error.message;
return fallback;
}
-function isDailyLimitError(error: unknown) {
- return isAISubscriptionError(error) && error.code === 'limit_reached';
-}
-
function estimateAiTimeUsedSeconds(content: string, limit: number) {
const words = content.trim() ? content.trim().split(/\s+/).length : 0;
return Math.min(limit, Math.max(1, Math.round((words / 140) * 60)));
@@ -218,9 +216,7 @@ export default function SparringPage() {
.catch((error: unknown) => {
if (aiRequestRef.current !== requestKey) return;
handleSubscriptionError(error);
- setAiError(isDailyLimitError(error)
- ? t('dailyLimit.reachedInline')
- : readableError(error, t('sparring.round.aiRequestFailed', 'The AI request failed. Check your connection and try again.')));
+ setAiError(readableError(error, t('sparring.round.aiRequestFailed', 'The AI request failed. Check your connection and try again.')));
})
.finally(() => {
if (aiRequestRef.current === requestKey) {
@@ -271,9 +267,7 @@ export default function SparringPage() {
.catch((error: unknown) => {
if (judgeRequestRef.current !== requestKey) return;
handleSubscriptionError(error);
- setJudgingError(isDailyLimitError(error)
- ? t('dailyLimit.reachedInline')
- : readableError(error, t('sparring.round.judgeRequestFailed', 'The judge request failed. Check your connection and try again.')));
+ setJudgingError(readableError(error, t('sparring.round.judgeRequestFailed', 'The judge request failed. Check your connection and try again.')));
})
.finally(() => {
if (judgeRequestRef.current === requestKey) {
diff --git a/src/test/crossfire/crossfire-daily-limit.test.tsx b/src/test/crossfire/crossfire-daily-limit.test.tsx
index dce4a6105..0d9d2f186 100644
--- a/src/test/crossfire/crossfire-daily-limit.test.tsx
+++ b/src/test/crossfire/crossfire-daily-limit.test.tsx
@@ -29,6 +29,7 @@ vi.mock('@/lib/ai/client', async () => {
const errors = await vi.importActual('@/lib/ai/errors');
return {
AIResponseError: errors.AIResponseError,
+ AIProviderError: errors.AIProviderError,
AISubscriptionError: errors.AISubscriptionError,
isAISubscriptionError: errors.isAISubscriptionError,
isAIModelRestrictionError: errors.isAIModelRestrictionError,
@@ -86,7 +87,11 @@ describe('Crossfire out of daily requests', () => {
fireEvent.click(screen.getByTestId('submit-turn'));
expect(await screen.findByRole('dialog')).toBeInTheDocument();
- expect(screen.getByText("You've used today's AI requests. They refresh at midnight UTC.")).toBeInTheDocument();
+ // Inline too, in the dialog's words rather than the server's English.
+ // The open dialog hides the page from the accessibility tree.
+ const inline = screen.getAllByRole('alert', { hidden: true }).find((alert) => alert.textContent?.includes('5 of 5 included AI uses'));
+ expect(inline).toBeDefined();
+ expect(screen.queryByText(/Upgrade to Pro for 50 daily requests/)).not.toBeInTheDocument();
expect(screen.getByTestId('end-crossfire')).toBeEnabled();
});