From ff486df8ca814f4f1d60600482b5349f9be5da4e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 16:03:37 +0000 Subject: [PATCH 1/2] Fix rate-limit overrides, AI error reports, and the login crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rate-limit lookups ordered by updated_at, which production does not have, so every override was ignored. Order by created_at and log the lookup failure. The new migration is not applied. Network failures are AINetworkError and stay out of Sentry. Unknown failures are AIUnknownError with a privacy-safe cause type. Other HTTP 5xx responses are AIServerError. A 401 or 403 from getUser signs out once and stops store reloads. Missing rows use maybeSingle. Edge Stripe uses the npm worker build and the fetch client. Optional analytics chunks no longer crash /login when a preload resolves to undefined. Fixes JAVASCRIPT-REACT-6 Fixes JAVASCRIPT-REACT-9 Co-authored-by: Gáborjáni-Szabó Ádám --- e2e/helpers/auth.ts | 11 ++ package.json | 2 +- src/components/WebTelemetry.tsx | 5 +- src/components/optional-telemetry.ts | 28 ++++ src/hooks/use-subscription.test.tsx | 83 +++++++++++ src/hooks/use-subscription.ts | 7 +- src/lib/ai/client.ts | 8 ++ src/lib/ai/error-display.ts | 3 +- src/lib/ai/errors.test.ts | 131 ++++++++++++++++++ src/lib/ai/errors.ts | 120 +++++++++++++++- src/lib/ai/request-failure.test.ts | 101 ++++++++++++++ src/lib/ai/request.ts | 21 ++- src/lib/ai/streaming.ts | 13 +- src/lib/observability.ts | 3 + src/lib/stores/debate-store.ts | 2 + src/lib/stores/session-store.ts | 2 + src/lib/stores/settings-store.ts | 2 + src/lib/stores/sparring-store.ts | 2 + src/lib/supabase/AuthProvider.tsx | 61 +++++++- src/lib/supabase/client.ts | 11 +- src/lib/supabase/session-expiry.ts | 83 +++++++++-- src/locales/en.json | 3 +- src/locales/es.json | 3 +- src/locales/hu.json | 3 +- src/test/auth/AuthProvider.behavior.test.tsx | 44 ++++++ src/test/auth/session-expiry.test.ts | 24 ++++ src/test/components/WebTelemetry.test.tsx | 27 ++++ .../functions/_shared/entitlements.test.ts | 77 +++++++++- supabase/functions/_shared/entitlements.ts | 41 +++--- supabase/functions/_shared/stripe.ts | 30 ++++ supabase/functions/_shared/stripe_test.ts | 19 +++ supabase/functions/account/index.ts | 16 +-- supabase/functions/admin/index.ts | 10 +- supabase/functions/ai-stream/handler_test.ts | 1 + supabase/functions/ai-stream/index_test.ts | 5 +- supabase/functions/ai/debate-context.test.ts | 5 +- .../functions/ai/generate-honesty_test.ts | 1 + .../functions/ai/handler-research.test.ts | 15 +- supabase/functions/ai/provider-errors_test.ts | 5 +- supabase/functions/ai/runtime.ts | 4 +- supabase/functions/ai/runtime_prompt_test.ts | 33 +++++ supabase/functions/ai/stream.test.ts | 5 +- supabase/functions/ai/voice.test.ts | 5 +- .../create-checkout/checkout-security.ts | 6 +- supabase/functions/create-checkout/index.ts | 9 +- supabase/functions/create-portal/index.ts | 7 +- supabase/functions/deno.json | 1 + supabase/functions/stripe-webhook/index.ts | 16 +-- supabase/functions/voice/index_test.ts | 3 + ...140000_rate_limits_order_by_created_at.sql | 15 ++ 50 files changed, 1027 insertions(+), 105 deletions(-) create mode 100644 src/components/optional-telemetry.ts create mode 100644 src/hooks/use-subscription.test.tsx create mode 100644 src/lib/ai/errors.test.ts create mode 100644 src/lib/ai/request-failure.test.ts create mode 100644 src/test/components/WebTelemetry.test.tsx create mode 100644 supabase/functions/_shared/stripe.ts create mode 100644 supabase/functions/_shared/stripe_test.ts create mode 100644 supabase/functions/ai/runtime_prompt_test.ts create mode 100644 supabase/migrations/20261022140000_rate_limits_order_by_created_at.sql diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts index f325f7b3d..379b1d230 100644 --- a/e2e/helpers/auth.ts +++ b/e2e/helpers/auth.ts @@ -53,6 +53,17 @@ export async function stubSupabaseSession(page: Page, opts: StubAuthOptions = {} body: JSON.stringify({ ...session, expires_at: Math.floor(Date.now() / 1000) + HOUR_SECONDS }), })); + // A restored session is confirmed with GET /user. A 401 or 403 signs the tab + // out, so the stub user has to answer that call or the page returns to /login. + await page.route((url) => url.pathname.endsWith('/auth/v1/user'), (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(session.user), + }); + }); + // 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/package.json b/package.json index 2f0f3afe0..95187ebf3 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "test:collaboration": "vitest run --config services/collaboration/vitest.config.ts", "start:collaboration": "tsx services/collaboration/src/index.ts", "test:watch": "vitest", - "test:edge": "deno test --allow-read --allow-env=DEMO_CODE_PEPPER,STRIPE_SECRET_KEY,STRIPE_PRICE_ID,STRIPE_PRICE_LOOKUP_KEY,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,ELEVENLABS_API_KEY,ELEVENLABS_VOICE_ID,OPENROUTER_API_KEY,OPENROUTER_API_URL,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_MAILER_FORMAT,WORKSPACE_INVITE_BASE_URL,WORKSPACE_INVITE_ALLOWED_ORIGINS,WORKSPACE_INVITE_SERVICE_ROLE_KEY --allow-net=127.0.0.1 supabase/functions/", + "test:edge": "deno test --config supabase/functions/deno.json --node-modules-dir=auto --allow-read --allow-env=DEMO_CODE_PEPPER,STRIPE_SECRET_KEY,STRIPE_PRICE_ID,STRIPE_PRICE_LOOKUP_KEY,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,ELEVENLABS_API_KEY,ELEVENLABS_VOICE_ID,OPENROUTER_API_KEY,OPENROUTER_API_URL,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_MAILER_FORMAT,WORKSPACE_INVITE_BASE_URL,WORKSPACE_INVITE_ALLOWED_ORIGINS,WORKSPACE_INVITE_SERVICE_ROLE_KEY --allow-net=127.0.0.1 supabase/functions/", "test:db": "supabase test db && bash scripts/test-daily-quota.sh && bash scripts/test-opus-default-migration.sh && bash scripts/test-workspace-invite-concurrency.sh && bash scripts/test-workspace-mutation-concurrency.sh", "test:e2e": "playwright test --project=desktop-chromium --project=mobile-chromium", "test:e2e:smoke": "playwright test --project=desktop-chromium --grep @smoke", diff --git a/src/components/WebTelemetry.tsx b/src/components/WebTelemetry.tsx index 996877a0c..a2475da7e 100644 --- a/src/components/WebTelemetry.tsx +++ b/src/components/WebTelemetry.tsx @@ -1,7 +1,8 @@ import { lazy, Suspense, useEffect, useState } from "react"; +import { loadOptionalComponent } from "@/components/optional-telemetry"; -const Analytics = lazy(() => import("@vercel/analytics/react").then(({ Analytics: Component }) => ({ default: Component }))); -const SpeedInsights = lazy(() => import("@vercel/speed-insights/react").then(({ SpeedInsights: Component }) => ({ default: Component }))); +const Analytics = lazy(() => loadOptionalComponent(() => import("@vercel/analytics/react"), "Analytics")); +const SpeedInsights = lazy(() => loadOptionalComponent(() => import("@vercel/speed-insights/react"), "SpeedInsights")); export function WebTelemetry() { const [ready, setReady] = useState(false); diff --git a/src/components/optional-telemetry.ts b/src/components/optional-telemetry.ts new file mode 100644 index 000000000..15e3467f3 --- /dev/null +++ b/src/components/optional-telemetry.ts @@ -0,0 +1,28 @@ +import type { ComponentType } from "react"; + +function HiddenTelemetry(): null { + return null; +} + +/** + * Analytics chunks are optional. Vite resolves a prevented preload error to + * undefined, and destructuring that argument throws + * "(destructured parameter) is undefined" in Firefox, which takes down /login. + */ +export function componentFromModule( + module: Record | null | undefined, + exportName: string, +): ComponentType { + const component = module?.[exportName]; + return typeof component === "function" ? component as ComponentType : HiddenTelemetry; +} + +export function loadOptionalComponent( + load: () => Promise | null | undefined>, + exportName: string, +): Promise<{ default: ComponentType }> { + return Promise.resolve() + .then(load) + .then((module) => ({ default: componentFromModule(module, exportName) })) + .catch(() => ({ default: HiddenTelemetry })); +} diff --git a/src/hooks/use-subscription.test.tsx b/src/hooks/use-subscription.test.tsx new file mode 100644 index 000000000..bfcc03cee --- /dev/null +++ b/src/hooks/use-subscription.test.tsx @@ -0,0 +1,83 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const orders: { table: string; column: string }[] = []; +let rateLimitResult: { data: { limit_value: number } | null; error: { message: string } | null } = { + data: { limit_value: 12 }, + error: null, +}; + +function builder(table: string) { + let column = ''; + const api = { + select: () => api, + eq: () => api, + gte: () => api, + neq: () => api, + not: () => api, + order: (next: string) => { + column = next; + orders.push({ table, column: next }); + return api; + }, + limit: () => api, + maybeSingle: () => Promise.resolve(table === 'user_settings' + ? { data: { subscription_tier: 'trial', daily_limit_override: null, trial_ends_at: '2099-01-01T00:00:00.000Z', grace_period_ends_at: null }, error: null } + : table === 'rate_limits' ? rateLimitResult : { data: null, error: null }), + then: (resolve: (value: unknown) => void, reject?: (reason: unknown) => void) => Promise.resolve( + table === 'subscriptions' + ? { data: [], error: null } + : table === 'ai_requests' + ? { count: 1, error: null } + : { data: null, error: null }, + ).then(resolve, reject), + }; + return api; +} + +vi.mock('@/lib/supabase/AuthProvider', () => ({ + useAuth: () => ({ user: { id: 'user-1' } }), +})); + +vi.mock('@/lib/supabase/client', () => ({ + supabase: { from: (table: string) => builder(table) }, +})); + +import { useSubscription } from './use-subscription'; + +function wrapper({ children }: { children: ReactNode }) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return {children}; +} + +describe('useSubscription rate limits', () => { + beforeEach(() => { + orders.length = 0; + rateLimitResult = { data: { limit_value: 12 }, error: null }; + }); + + it('reads the account limit ordered by created_at and uses it', async () => { + const { result } = renderHook(() => useSubscription(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + const rateOrders = orders.filter((order) => order.table === 'rate_limits'); + expect(rateOrders.length).toBeGreaterThan(0); + expect(rateOrders.every((order) => order.column === 'created_at')).toBe(true); + expect(orders.some((order) => order.column === 'updated_at')).toBe(false); + expect(result.current.dailyLimit).toBe(12); + expect(result.current.remainingToday).toBe(11); + }); + + it('logs a lookup error and leaves the remaining count unknown', async () => { + rateLimitResult = { data: null, error: { message: 'column rate_limits.updated_at does not exist' } }; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { result } = renderHook(() => useSubscription(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(warn).toHaveBeenCalledWith('[useSubscription] rate_limits lookup failed:', 'column rate_limits.updated_at does not exist'); + expect(result.current.remainingToday).toBeNull(); + warn.mockRestore(); + }); +}); diff --git a/src/hooks/use-subscription.ts b/src/hooks/use-subscription.ts index 6119fca68..8e4ce40f7 100644 --- a/src/hooks/use-subscription.ts +++ b/src/hooks/use-subscription.ts @@ -129,10 +129,13 @@ async function fetchSubscription(userId: string): Promise .select('limit_value') .eq('user_id', userId) .eq('limit_type', 'daily_requests') - .order('updated_at', { ascending: false }) + .order('created_at', { ascending: false }) .limit(1) .maybeSingle(); - if (limitError) limitKnown = false; + if (limitError) { + console.warn('[useSubscription] rate_limits lookup failed:', limitError.message); + limitKnown = false; + } else if (typeof ownLimit?.limit_value === 'number') snapshot.dailyLimit = ownLimit.limit_value; } diff --git a/src/lib/ai/client.ts b/src/lib/ai/client.ts index c49f165fd..ecf60ea6b 100644 --- a/src/lib/ai/client.ts +++ b/src/lib/ai/client.ts @@ -8,7 +8,11 @@ export { AIProviderError, AIResponseError, TimeoutError, + AINetworkError, + AIServerError, + AIUnknownError, isAIAuthError, + isAINetworkError, isAIRateLimitError, isAIProviderError, isTimeoutError, @@ -16,6 +20,10 @@ export { isAIModelRestrictionError, classifyAIError, mapAIError, + isBrowserNetworkFailure, + browserIsOffline, + privacySafeCauseType, + offlineAIMessage, } from './errors'; export type { AIErrorClassification } from './errors'; export { diff --git a/src/lib/ai/error-display.ts b/src/lib/ai/error-display.ts index 17ab03524..186a2af74 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, isAISubscriptionError } from './client'; +import { AIProviderError, isAINetworkError, isAISubscriptionError } from './errors'; import { subscriptionLimitMessage } from './limit-message'; const PROVIDER_LABELS: Record = { @@ -21,6 +21,7 @@ function providerLabel(provider?: string, message = ''): string { export { subscriptionLimitMessage }; export function getUserFacingAIError(error: unknown): string { + if (isAINetworkError(error)) return i18n.t('aiErrors.offline'); if (isAISubscriptionError(error)) return subscriptionLimitMessage(error); if (error instanceof AIProviderError || (error instanceof Error && error.name === 'AIProviderError')) { diff --git a/src/lib/ai/errors.test.ts b/src/lib/ai/errors.test.ts new file mode 100644 index 000000000..12a47f9dd --- /dev/null +++ b/src/lib/ai/errors.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest'; +import { + AIAuthError, + AIModelRestrictionError, + AINetworkError, + AISubscriptionError, + AIProviderError, + AIServerError, + AIUnknownError, + TimeoutError, + classifyAIError, + mapAIError, +} from './errors'; + +describe('classifyAIError', () => { + it('treats a fetch TypeError as a network failure and does not capture it', () => { + const classification = classifyAIError(new TypeError('Failed to fetch')); + expect(classification).toMatchObject({ errorName: 'AINetworkError', shouldCaptureException: false }); + }); + + it('captures an unknown error under its own name with a privacy-safe cause type', () => { + const classification = classifyAIError(new Error('student motion and private notes')); + expect(classification).toMatchObject({ + errorName: 'AIUnknownError', + shouldCaptureException: true, + causeType: 'Error:Error', + }); + expect(classification.causeType).not.toContain('student'); + expect(JSON.stringify(classification)).not.toContain('private notes'); + }); + + it('keeps subscription, model and timeout failures on their existing names', () => { + expect(classifyAIError(new AISubscriptionError('limit', { statusCode: 402 })).shouldCaptureException).toBe(false); + expect(classifyAIError(new AISubscriptionError('limit', { statusCode: 402 })).errorName).toBe('AISubscriptionError'); + expect(classifyAIError(new AIModelRestrictionError('upgrade')).errorName).toBe('AIModelRestrictionError'); + expect(classifyAIError(new AIAuthError('no', { statusCode: 403 })).shouldCaptureException).toBe(false); + expect(classifyAIError(new TimeoutError())).toMatchObject({ errorName: 'TimeoutError', shouldCaptureException: true }); + }); + + it('recognizes the other browser network messages and an offline navigator', () => { + expect(classifyAIError(new TypeError('NetworkError when attempting to fetch resource')).errorName).toBe('AINetworkError'); + expect(classifyAIError(new TypeError('Load failed')).errorName).toBe('AINetworkError'); + expect(classifyAIError(new TypeError('Network error')).errorName).toBe('AINetworkError'); + const online = navigator.onLine; + Object.defineProperty(navigator, 'onLine', { configurable: true, value: false }); + try { + expect(classifyAIError(new Error('socket closed')).errorName).toBe('AINetworkError'); + expect(classifyAIError(new AISubscriptionError('limit', { statusCode: 402 })).errorName).toBe('AISubscriptionError'); + } finally { + Object.defineProperty(navigator, 'onLine', { configurable: true, value: online }); + } + }); +}); + +describe('mapAIError', () => { + it('maps a wrapped fetch failure to a network error without a status code', () => { + const error = mapAIError( + { message: 'Failed to send a request to the Edge Function', context: new TypeError('Failed to fetch') }, + 'req-1', + 'openrouter', + 'm', + ); + expect(error).toBeInstanceOf(AINetworkError); + expect(error.statusCode).toBeUndefined(); + expect(classifyAIError(error).shouldCaptureException).toBe(false); + }); + + it('maps an unknown transport error without putting its message in the cause type', () => { + const error = mapAIError({ message: 'student motion leaked' }, 'req-1', 'openrouter', 'm'); + expect(error).toBeInstanceOf(AIUnknownError); + expect((error as AIUnknownError).causeType).toBe('Unknown:Unknown'); + expect((error as AIUnknownError).causeType).not.toContain('student'); + expect(classifyAIError(error).shouldCaptureException).toBe(true); + }); + + it('still maps 402, 403 and abort messages as before', () => { + const limited = mapAIError( + { message: 'Edge Function returned a non-2xx status code', context: { status: 402 } }, + 'req-1', + 'openrouter', + 'm', + { error: 'limit', code: 'limit_reached' }, + ); + expect(limited).toBeInstanceOf(AISubscriptionError); + expect(limited.statusCode).toBe(402); + + const upgrade = mapAIError( + { message: 'Edge Function returned a non-2xx status code', context: { status: 403 } }, + 'req-1', + 'openrouter', + 'm', + { code: 'upgrade_required', error: 'This model requires Pro' }, + ); + expect(upgrade).toBeInstanceOf(AIModelRestrictionError); + + const forbidden = mapAIError( + { message: 'Edge Function returned a non-2xx status code', context: { status: 403 } }, + 'req-1', + 'openrouter', + 'm', + { error: 'Access denied' }, + ); + expect(forbidden).toBeInstanceOf(AIAuthError); + + expect(mapAIError({ message: 'The operation was aborted' }, 'req-1', 'openrouter', 'm')).toBeInstanceOf(TimeoutError); + }); + + it('keeps a provider_error 500 on AIProviderError and other 500s on AIServerError', () => { + const provider = mapAIError( + { message: 'Edge Function returned a non-2xx status code', context: { status: 500 } }, + 'req-1', + 'openrouter', + 'm', + { error: 'AI request failed. Please try again.', code: 'provider_error' }, + ); + expect(provider).toBeInstanceOf(AIProviderError); + expect(provider.statusCode).toBe(500); + expect(classifyAIError(provider).errorName).toBe('AIProviderError'); + + const server = mapAIError( + { message: 'Edge Function returned a non-2xx status code', context: { status: 500 } }, + 'req-1', + 'openrouter', + 'm', + { error: 'Unable to verify your quota. Please try again.' }, + ); + expect(server).toBeInstanceOf(AIServerError); + expect(server.statusCode).toBe(500); + expect(classifyAIError(server)).toMatchObject({ errorName: 'AIServerError', statusCode: 500, shouldCaptureException: true }); + }); +}); diff --git a/src/lib/ai/errors.ts b/src/lib/ai/errors.ts index 7f01104ca..d3996e304 100644 --- a/src/lib/ai/errors.ts +++ b/src/lib/ai/errors.ts @@ -1,3 +1,4 @@ +import i18n from '@/i18n'; import { localizedLimitMessage } from './limit-message'; /** @@ -123,6 +124,93 @@ export class TimeoutError extends AIError { } } +const NETWORK_MARKERS = [ + 'failed to fetch', + 'networkerror when attempting to fetch resource', + 'load failed', + 'network error', +]; + +function readErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string') return error.message; + return ''; +} + +function looksLikeFetchTypeError(error: unknown): boolean { + if (!(error instanceof TypeError) && !(error instanceof Error && error.name === 'TypeError')) return false; + const message = readErrorMessage(error).toLowerCase(); + return NETWORK_MARKERS.some((marker) => message.includes(marker)); +} + +/** A browser fetch that never reached the server, including the error supabase-js wraps around it. */ +export function isBrowserNetworkFailure(error: unknown): boolean { + if (looksLikeFetchTypeError(error)) return true; + if (error && typeof error === 'object' && 'context' in error) { + const context = (error as { context?: unknown }).context; + if (context && context !== error && looksLikeFetchTypeError(context)) return true; + } + return false; +} + +export function browserIsOffline(): boolean { + return typeof navigator !== 'undefined' && navigator.onLine === false; +} + +function safeIdentifier(value: string): string { + const cleaned = value.replace(/[^A-Za-z0-9_]/g, ''); + return cleaned.slice(0, 40) || 'Error'; +} + +/** Name and constructor only. The message can carry request text and stays out of telemetry. */ +export function privacySafeCauseType(error: unknown): string { + const nested = error && typeof error === 'object' && 'context' in error + ? (error as { context?: unknown }).context + : undefined; + const source = nested instanceof Error ? nested : error; + if (!(source instanceof Error)) return 'Unknown:Unknown'; + return `${safeIdentifier(source.name || 'Error')}:${safeIdentifier(source.constructor?.name || 'Error')}`; +} + +export function offlineAIMessage(): string { + return i18n.t('aiErrors.offline'); +} + +/** The browser could not reach the AI function. Not an application failure. */ +export class AINetworkError extends AIError { + constructor(message: string = offlineAIMessage(), options?: { requestId?: string }) { + super(message, { requestId: options?.requestId }); + this.name = 'AINetworkError'; + } +} + +/** + * The function answered with a 5xx that is not a recognized provider failure. + * Kept separate from a plain AIError so a server failure is not grouped with + * a request that never left the browser. + */ +export class AIServerError extends AIError { + constructor(message: string, options?: { statusCode?: number; requestId?: string }) { + super(message, { statusCode: options?.statusCode ?? 500, requestId: options?.requestId }); + this.name = 'AIServerError'; + } +} + +/** A failure that is not one of the known AI errors. Captured under its own name. */ +export class AIUnknownError extends AIError { + readonly causeType: string; + + constructor(message: string, options?: { requestId?: string; causeType?: string }) { + super(message, { requestId: options?.requestId }); + this.name = 'AIUnknownError'; + this.causeType = options?.causeType ?? 'Error:Error'; + } +} + +export function isAINetworkError(error: unknown): error is AINetworkError { + return error instanceof AINetworkError || (error instanceof Error && error.name === 'AINetworkError'); +} + /** * Type guards for error checking. */ @@ -154,6 +242,8 @@ export interface AIErrorClassification { errorName: string; statusCode?: number; shouldCaptureException: boolean; + /** Privacy-safe origin of an unknown failure: `name:Constructor`. Never the message. */ + causeType?: string; } /** Classifies AI failures into stable, privacy-safe telemetry fields. */ @@ -171,6 +261,18 @@ export function classifyAIError(error: unknown, userInitiatedAbort = false): AIE return { errorName: 'AIResponseError', statusCode, shouldCaptureException: true }; } if (isAIProviderError(error)) return { errorName: 'AIProviderError', statusCode, shouldCaptureException: true }; + if (error instanceof AIServerError || (error instanceof Error && error.name === 'AIServerError')) { + return { errorName: 'AIServerError', statusCode, shouldCaptureException: true }; + } + if (isAINetworkError(error) || isBrowserNetworkFailure(error) || (browserIsOffline() && !(error instanceof AIError))) { + return { errorName: 'AINetworkError', shouldCaptureException: false }; + } + if (error instanceof AIUnknownError) { + return { errorName: 'AIUnknownError', statusCode, shouldCaptureException: true, causeType: error.causeType }; + } + if (!(error instanceof AIError)) { + return { errorName: 'AIUnknownError', shouldCaptureException: true, causeType: privacySafeCauseType(error) }; + } return { errorName, statusCode, shouldCaptureException: true }; } @@ -271,10 +373,24 @@ export function mapAIError( }); } - // Default: return generic error with helpful message + // No HTTP status: the function was never reached, or the failure is not one we know. + // A real 5xx stays AIServerError. Paid, auth, and timeout responses are classified above. + if (!statusCode && (isBrowserNetworkFailure(error) || browserIsOffline())) { + return new AINetworkError(offlineAIMessage(), { requestId }); + } + if (!statusCode) { + return new AIUnknownError(i18n.t('aiErrors.unexpected'), { requestId, causeType: privacySafeCauseType(error) }); + } + if (statusCode >= 500) { + return new AIServerError( + `AI service error: ${message}. Please try again.`, + { statusCode, requestId } + ); + } + return new AIError( `AI service error: ${message}. Please try again.`, - { statusCode: statusCode || 500, requestId } + { statusCode, requestId } ); } diff --git a/src/lib/ai/request-failure.test.ts b/src/lib/ai/request-failure.test.ts new file mode 100644 index 000000000..0b079bf1d --- /dev/null +++ b/src/lib/ai/request-failure.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { invokeMock, captureExceptionMock, captureProductEventMock } = vi.hoisted(() => ({ + invokeMock: vi.fn(), + captureExceptionMock: vi.fn(), + captureProductEventMock: vi.fn(), +})); + +vi.mock('@/lib/observability', () => ({ + captureException: captureExceptionMock, + captureProductEvent: captureProductEventMock, +})); + +vi.mock('@/lib/supabase/client', () => ({ + getActiveAccessToken: () => 'token', + invokeAuthenticatedFunction: invokeMock, + invokeAuthenticatedFunctionWith: invokeMock, + supabase: { auth: { getSession: vi.fn() } }, +})); + +import { AIAuthError, AIModelRestrictionError, AINetworkError, AISubscriptionError, AIUnknownError, TimeoutError } from '@/lib/ai/errors'; +import { callSupabaseAI } from '@/lib/ai/request'; + +const config = { provider: 'openrouter' as const, model: 'openai/gpt-6-luna', reasoningEffort: 'low' as const }; + +function failedEvents() { + return captureProductEventMock.mock.calls.filter((call) => call[0] === 'ai_request_failed'); +} + +describe('callSupabaseAI failure reporting', () => { + beforeEach(() => { + invokeMock.mockReset(); + captureExceptionMock.mockReset(); + captureProductEventMock.mockReset(); + }); + + it('does not capture a fetch TypeError and still records the network failure', async () => { + invokeMock.mockRejectedValue(new TypeError('Failed to fetch')); + + await expect(callSupabaseAI('generate', { motion: 'Made up motion' }, config, { debateId: null })).rejects.toBeInstanceOf(AINetworkError); + + expect(captureExceptionMock).not.toHaveBeenCalled(); + expect(failedEvents()).toEqual([ + expect.arrayContaining(['ai_request_failed', expect.objectContaining({ feature: 'generate', errorName: 'AINetworkError' })]), + ]); + const properties = failedEvents()[0][1] as Record; + expect(properties.statusCode).toBeUndefined(); + expect(JSON.stringify(properties)).not.toContain('Failed to fetch'); + }); + + it('captures an unknown error with the cause type and not the message', async () => { + invokeMock.mockRejectedValue(new RangeError('student motion leaked')); + + await expect(callSupabaseAI('generate', {}, config, { debateId: null })).rejects.toBeInstanceOf(AIUnknownError); + + expect(captureExceptionMock).toHaveBeenCalledTimes(1); + const [captured, extra] = captureExceptionMock.mock.calls[0] as [Error, Record]; + expect(captured.name).toBe('AIUnknownError'); + expect(extra.causeType).toBe('RangeError:RangeError'); + expect(JSON.stringify(extra)).not.toContain('student'); + expect(failedEvents()[0][1]).toMatchObject({ errorName: 'AIUnknownError', feature: 'generate' }); + }); + + it('keeps 402, 403 and timeouts on their existing reporting', async () => { + invokeMock.mockResolvedValueOnce({ + data: null, + error: { + message: 'Edge Function returned a non-2xx status code', + context: { status: 402, json: async () => ({ error: 'limit', code: 'limit_reached', limit: 5, current: 5 }) }, + }, + }); + await expect(callSupabaseAI('generate', {}, config, { debateId: null })).rejects.toBeInstanceOf(AISubscriptionError); + expect(captureExceptionMock).not.toHaveBeenCalled(); + expect(failedEvents().at(-1)?.[1]).toMatchObject({ errorName: 'AISubscriptionError', statusCode: 402 }); + + invokeMock.mockResolvedValueOnce({ + data: null, + error: { + message: 'Edge Function returned a non-2xx status code', + context: { status: 403, json: async () => ({ error: 'This model requires Pro', code: 'upgrade_required' }) }, + }, + }); + await expect(callSupabaseAI('generate', {}, config, { debateId: null })).rejects.toBeInstanceOf(AIModelRestrictionError); + expect(captureExceptionMock).not.toHaveBeenCalled(); + + invokeMock.mockResolvedValueOnce({ + data: null, + error: { + message: 'Edge Function returned a non-2xx status code', + context: { status: 403, json: async () => ({ error: 'Access denied' }) }, + }, + }); + await expect(callSupabaseAI('generate', {}, config, { debateId: null })).rejects.toBeInstanceOf(AIAuthError); + expect(captureExceptionMock).not.toHaveBeenCalled(); + + invokeMock.mockRejectedValueOnce(new DOMException('The operation was aborted.', 'AbortError')); + await expect(callSupabaseAI('generate', {}, config, { debateId: null })).rejects.toBeInstanceOf(TimeoutError); + expect(captureExceptionMock).toHaveBeenCalledTimes(1); + expect(captureExceptionMock.mock.calls[0][0].name).toBe('TimeoutError'); + }); +}); diff --git a/src/lib/ai/request.ts b/src/lib/ai/request.ts index 9d67305d8..06dc7be68 100644 --- a/src/lib/ai/request.ts +++ b/src/lib/ai/request.ts @@ -21,8 +21,15 @@ import { AIProviderError, AIResponseError, TimeoutError, + AINetworkError, + AIServerError, + AIUnknownError, classifyAIError, mapAIError, + isBrowserNetworkFailure, + browserIsOffline, + privacySafeCauseType, + offlineAIMessage, isProviderErrorMessage, isProviderErrorCode, } from './errors'; @@ -130,7 +137,11 @@ export function reportAIFailure(report: AIFailureReport): void { if (classification.shouldCaptureException) { const safeError = new Error(classification.errorName); safeError.name = classification.errorName; - captureException(safeError, { ...properties, requestId: report.requestId }); + captureException(safeError, { + ...properties, + requestId: report.requestId, + ...(classification.causeType ? { causeType: classification.causeType } : {}), + }); } } @@ -289,8 +300,8 @@ export async function callSupabaseAI( }); } - // Generic error from edge function - throw new AIError(errorMessage, { statusCode: 500, requestId }); + // The function reported a failure that is not a limit, auth, or provider error. + throw new AIServerError(errorMessage, { statusCode: 500, requestId }); } // Validate response has expected output @@ -378,7 +389,9 @@ export async function callSupabaseAI( throw timeoutError; } - const wrappedError = new AIError(message, { requestId }); + const wrappedError = isBrowserNetworkFailure(err) || browserIsOffline() + ? new AINetworkError(offlineAIMessage(), { requestId }) + : new AIUnknownError(i18n.t('aiErrors.unexpected'), { requestId, causeType: privacySafeCauseType(err) }); reportFailure(wrappedError); throw wrappedError; } finally { diff --git a/src/lib/ai/streaming.ts b/src/lib/ai/streaming.ts index ec72f1a68..23cb062aa 100644 --- a/src/lib/ai/streaming.ts +++ b/src/lib/ai/streaming.ts @@ -1,6 +1,7 @@ +import i18n from '@/i18n'; import { getActiveAccessToken, supabase, SUPABASE_URL } from '@/lib/supabase/client'; import { captureProductEvent } from '@/lib/observability'; -import { AIAuthError, AIResponseError, REQUEST_TIMEOUT_MS, TimeoutError, boundAskHistory, boundAskMessage, debateContextForRequest, mapAIError, refreshDebatesIfMissing, reportAIFailure, withOutputLanguage } from './client'; +import { AIAuthError, AIError, AINetworkError, AIResponseError, AIUnknownError, REQUEST_TIMEOUT_MS, TimeoutError, boundAskHistory, boundAskMessage, browserIsOffline, debateContextForRequest, isBrowserNetworkFailure, mapAIError, offlineAIMessage, privacySafeCauseType, refreshDebatesIfMissing, reportAIFailure, withOutputLanguage } from './client'; import { parseDebateContextUsage, useDebateContextStore } from '@/lib/stores/debate-context-store'; import type { ProviderConfig } from './providers/types'; import { AskOutputSchema, type AskOutput } from './schemas/ask'; @@ -264,11 +265,13 @@ export async function streamAskChat( ? abortError() : controller.signal.aborted ? new TimeoutError() - : error; + : error instanceof AIError + ? error + : isBrowserNetworkFailure(error) || browserIsOffline() + ? new AINetworkError(offlineAIMessage(), { requestId }) + : new AIUnknownError(i18n.t('aiErrors.unexpected'), { requestId, causeType: privacySafeCauseType(error) }); captureStreamFailure(reportedError, provider, config.model, startTime, requestId, callerAborted); - if (callerAborted) throw reportedError; - if (controller.signal.aborted) throw reportedError; - throw error; + throw reportedError; } finally { clearTimeout(timeoutId); handlers.signal?.removeEventListener('abort', abortForCaller); diff --git a/src/lib/observability.ts b/src/lib/observability.ts index 1a2e71858..4745bb775 100644 --- a/src/lib/observability.ts +++ b/src/lib/observability.ts @@ -85,8 +85,11 @@ const STRING_PROPERTY_VALUES: Record> = { action: new Set(["calculate", "draw", "print", "start"]), errorName: new Set([ "AIAuthError", + "AINetworkError", + "AIUnknownError", "AIModelRestrictionError", "AIProviderError", + "AIServerError", "AIRateLimitError", "AIResponseError", "AISubscriptionError", diff --git a/src/lib/stores/debate-store.ts b/src/lib/stores/debate-store.ts index ae608af02..3c5b44665 100644 --- a/src/lib/stores/debate-store.ts +++ b/src/lib/stores/debate-store.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; import { supabase } from '@/lib/supabase/client'; +import { restoredSessionsBlocked } from '@/lib/supabase/session-expiry'; import { currentUserId } from '@/lib/supabase/current-user'; /** A favor, En contra, both sides, or not side-specific. */ @@ -125,6 +126,7 @@ export const useDebateStore = create()( dialogMotion: '', hydrate: async () => { + if (restoredSessionsBlocked()) return; const epoch = hydrationEpoch; const userId = await currentUserId(); if (epoch !== hydrationEpoch || !userId) return; diff --git a/src/lib/stores/session-store.ts b/src/lib/stores/session-store.ts index cb1890eaa..24faccfe2 100644 --- a/src/lib/stores/session-store.ts +++ b/src/lib/stores/session-store.ts @@ -8,6 +8,7 @@ import type { BrainstormOutput, BrainstormInput } from '../ai/schemas/brainstorm import type { FactoidsOutput, FactoidsInput } from '../ai/schemas/factoids'; import type { AskOutput } from '../ai/schemas/ask'; import { supabase } from '@/lib/supabase/client'; +import { restoredSessionsBlocked } from '@/lib/supabase/session-expiry'; import { currentUserId } from '@/lib/supabase/current-user'; import i18n from '@/i18n'; import { activeDebateId, useDebateStore, type DebateSide } from './debate-store'; @@ -1159,6 +1160,7 @@ export const useSessionStore = create()( }, hydrateFromServer: async () => { + if (restoredSessionsBlocked()) return; const results = await Promise.allSettled([ supabase.from('cases').select('*').order('created_at', { ascending: false }), supabase.from('attacks').select('*').order('created_at', { ascending: false }), diff --git a/src/lib/stores/settings-store.ts b/src/lib/stores/settings-store.ts index e887c0805..e24d052b1 100644 --- a/src/lib/stores/settings-store.ts +++ b/src/lib/stores/settings-store.ts @@ -14,6 +14,7 @@ import { } from '../../../supabase/functions/_shared/model-catalog'; import type { ProviderConfig } from '../ai/providers/types'; import { supabase } from '@/lib/supabase/client'; +import { restoredSessionsBlocked } from '@/lib/supabase/session-expiry'; import { currentUserId } from '@/lib/supabase/current-user'; export type FeatureTab = 'generate' | 'attack' | 'brainstorm' | 'factoids' | 'ask' | 'sparring'; @@ -304,6 +305,7 @@ export const useSettingsStore = create()( }, hydrateFromServer: async () => { + if (restoredSessionsBlocked()) return; const { data, error } = await supabase .from('user_settings') .select('*') diff --git a/src/lib/stores/sparring-store.ts b/src/lib/stores/sparring-store.ts index c47ecd78f..d3969c8a7 100644 --- a/src/lib/stores/sparring-store.ts +++ b/src/lib/stores/sparring-store.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; import { z } from 'zod'; import { supabase } from '@/lib/supabase/client'; +import { restoredSessionsBlocked } from '@/lib/supabase/session-expiry'; import { currentUserId } from '@/lib/supabase/current-user'; import { SparringScorecardSchema, @@ -477,6 +478,7 @@ export const useSparringStore = create()( }, hydrateSavedSessions: async () => { + if (restoredSessionsBlocked()) return; if (get().hydrated) return; const epoch = hydrationEpoch; const userId = await currentUserId(); diff --git a/src/lib/supabase/AuthProvider.tsx b/src/lib/supabase/AuthProvider.tsx index c7a3c5255..67e55dedd 100644 --- a/src/lib/supabase/AuthProvider.tsx +++ b/src/lib/supabase/AuthProvider.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { Session } from '@supabase/supabase-js'; import { AUTH_STORAGE_KEY, setActiveAccessToken, supabase } from './client'; +import { allowSessionsAfterSignIn, rejectDeadSession, restoredSessionsBlocked } from './session-expiry'; import { AuthContext, useAuth, type AuthContextValue } from './auth-context'; import { useSessionStore } from '@/lib/stores/session-store'; import { useSettingsStore } from '@/lib/stores/settings-store'; @@ -86,6 +87,12 @@ function withAuthTimeout(operation: Promise, message: string, timeoutMs = ]); } +function isDeadUserLookup(error: unknown): boolean { + if (!error || typeof error !== 'object' || !('status' in error)) return false; + const status = (error as { status?: unknown }).status; + return status === 401 || status === 403; +} + function clearGoogleTokens() { sessionStorage.removeItem('google_provider_token'); localStorage.removeItem('google_provider_token'); @@ -120,6 +127,32 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { useEffect(() => { let mounted = true; let hydratedUserId: string | undefined; + const confirmations = new Map>(); + const confirmRestoredSession = (accessToken: string): Promise => { + const cached = confirmations.get(accessToken); + if (cached) return cached; + const pending = (async () => { + if (restoredSessionsBlocked()) return false; + if (typeof supabase.auth.getUser !== 'function') return true; + try { + const { error } = await supabase.auth.getUser(); + if (!isDeadUserLookup(error)) return true; + await rejectDeadSession(supabase.auth); + return false; + } catch { + // Auth did not answer. Keep the local session; this is an outage, not a dead login. + return true; + } + })(); + confirmations.set(accessToken, pending); + return pending; + }; + const dropRestoredSession = () => { + setActiveAccessToken(null); + setSession(null); + currentUserIdRef.current = undefined; + setJamUserId(undefined); + }; const hydrateStores = (userId?: string) => { if (!userId || hydratedUserId === userId) return; hydratedUserId = userId; @@ -201,6 +234,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (!mounted) return; const { data, error } = getSessionResult; if (error) console.warn('[Auth] getSession error:', error.message); + if (data.session?.access_token && !(await confirmRestoredSession(data.session.access_token))) { + if (!mounted) return; + dropRestoredSession(); + return; + } + if (!mounted) return; setActiveAccessToken(data.session?.access_token ?? null, data.session?.user.id ?? null); setSession(data.session ?? null); currentUserIdRef.current = data.session?.user?.id; @@ -225,6 +264,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const { data: authListener } = supabase.auth.onAuthStateChange((event, newSession) => { if (import.meta.env.DEV) console.log('[Auth] Event:', event, 'Provider Token:', newSession?.provider_token ? 'present' : 'null'); + if (newSession && restoredSessionsBlocked()) { + dropRestoredSession(); + setLoading(false); + return; + } + const prevUserId = currentUserIdRef.current; const nextUserId = newSession?.user?.id; clearCrossfireDraftOnAuthChange(prevUserId, nextUserId); @@ -261,7 +306,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { useDebateContextStore.getState().resetLocalData(); } - hydrateStores(nextUserId); + if (newSession?.access_token) { + void confirmRestoredSession(newSession.access_token).then((alive) => { + if (!mounted || !alive || restoredSessionsBlocked()) { + if (mounted && !alive) dropRestoredSession(); + return; + } + hydrateStores(newSession.user?.id); + }); + } else { + hydrateStores(nextUserId); + } setLoading(false); }); @@ -296,6 +351,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { user: session?.user ?? null, loading, signInWithGoogle: async ({ driveAccess = false } = {}) => { + allowSessionsAfterSignIn(); // Always use the SDK-generated URL so PKCE's code_verifier is written // to storage before redirect. The previous Promise.race against a 1200ms // timeout could fall back to a hand-built URL that skipped PKCE — Google @@ -317,6 +373,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { await openExternalUrl(data.url); }, signInWithEmailOtp: async (email) => { + allowSessionsAfterSignIn(); const normalizedEmail = email.trim(); if (!normalizedEmail) throw new Error('Email is required.'); @@ -327,6 +384,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (error) throw error; }, verifyEmailOtp: async (email, token) => { + allowSessionsAfterSignIn(); const normalizedEmail = email.trim(); const normalizedToken = token.trim(); if (!normalizedEmail) throw new Error('Email is required.'); @@ -340,6 +398,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (error) throw error; }, signInWithDemoCode: async (code) => { + allowSessionsAfterSignIn(); const normalizedCode = code.trim().toUpperCase().replace(/\s+/g, ''); if (!normalizedCode) throw new Error('Demo code is required.'); diff --git a/src/lib/supabase/client.ts b/src/lib/supabase/client.ts index 56b32021f..193866f05 100644 --- a/src/lib/supabase/client.ts +++ b/src/lib/supabase/client.ts @@ -44,8 +44,15 @@ function requestUrl(input: RequestInfo | URL): string { // 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); + const url = requestUrl(input); + if (activeAccessToken && isAuthRejection(url, response.status)) { + let userLookup = false; + try { + userLookup = new URL(url, SUPABASE_URL).pathname === '/auth/v1/user'; + } catch { + userLookup = false; + } + setTimeout(() => void verifySessionAfterRejection(supabase.auth, Date.now, { userLookup }), 0); } return response; }; diff --git a/src/lib/supabase/session-expiry.ts b/src/lib/supabase/session-expiry.ts index fab29a27e..c597b0c44 100644 --- a/src/lib/supabase/session-expiry.ts +++ b/src/lib/supabase/session-expiry.ts @@ -6,6 +6,8 @@ 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; +const BACKOFF_START_MS = 1_000; +const BACKOFF_CAP_MS = 60_000; type SessionAuth = { refreshSession: () => Promise<{ error: unknown }>; @@ -27,25 +29,82 @@ export function refreshMeansRevoked(error: unknown): boolean { let checking = false; let confirmedAt = Number.NEGATIVE_INFINITY; +let backoffMs = BACKOFF_START_MS; +let retryNotBefore = Number.NEGATIVE_INFINITY; +let dead = false; +let signingOut: Promise | null = null; + +/** A restored session was refused. Further SIGNED_IN events must not reload data. */ +export function restoredSessionsBlocked(): boolean { + return dead; +} + +/** An explicit sign-in is a new session, so stop ignoring restores. */ +export function allowSessionsAfterSignIn(): void { + dead = false; + backoffMs = BACKOFF_START_MS; + retryNotBefore = Number.NEGATIVE_INFINITY; +} + +/** + * The server refused this browser's user. Sign out once, locally, and ignore + * any session the client restores afterwards. + */ +export async function rejectDeadSession(auth: SessionAuth): Promise { + dead = true; + if (signingOut) return signingOut; + signingOut = (async () => { + markSessionExpired(); + try { + // Local scope: the server already dropped this session, and other devices are not ours to sign out. + await auth.signOut({ scope: 'local' }); + } catch (error) { + console.warn('[Auth] Local sign-out after a refused session failed:', error); + } + })(); + return signingOut; +} /** - * 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). + * A request was refused for auth. A 401/403 from Auth's own user lookup means + * the session is already dead, so sign out without another refresh (a refresh + * can succeed and keep the loop going). A REST 401 still gets one refresh. + * Outages back off. They never retry on a fixed short interval. + * 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'; +export async function verifySessionAfterRejection( + auth: SessionAuth, + now: () => number = Date.now, + options?: { userLookup?: boolean }, +): Promise<'kept' | 'expired' | 'skipped'> { + if (dead) return 'expired'; + if (checking) return 'skipped'; + const clock = now(); + if (clock - confirmedAt < RECHECK_AFTER_MS || clock < retryNotBefore) return 'skipped'; checking = true; try { + if (options?.userLookup) { + await rejectDeadSession(auth); + return 'expired'; + } 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' }); + if (!error) { + confirmedAt = clock; + backoffMs = BACKOFF_START_MS; + retryNotBefore = Number.NEGATIVE_INFINITY; + return 'kept'; + } + if (!refreshMeansRevoked(error)) { + retryNotBefore = clock + backoffMs; + backoffMs = Math.min(backoffMs * 2, BACKOFF_CAP_MS); + return 'kept'; + } + await rejectDeadSession(auth); return 'expired'; } catch (error) { console.warn('[Auth] Session check after a refused request failed:', error); + retryNotBefore = clock + backoffMs; + backoffMs = Math.min(backoffMs * 2, BACKOFF_CAP_MS); return 'kept'; } finally { checking = false; @@ -56,6 +115,10 @@ export async function verifySessionAfterRejection(auth: SessionAuth, now: () => export function resetSessionExpiryCheck(): void { checking = false; confirmedAt = Number.NEGATIVE_INFINITY; + backoffMs = BACKOFF_START_MS; + retryNotBefore = Number.NEGATIVE_INFINITY; + dead = false; + signingOut = null; } function markSessionExpired(): void { diff --git a/src/locales/en.json b/src/locales/en.json index fc2aa04a9..5378d55f0 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2438,7 +2438,8 @@ "unavailable": "{{provider}} is temporarily unavailable. Please try again later.", "safety": "{{provider}} blocked this request for safety reasons. Try rephrasing your input.", "unexpectedProvider": "The AI provider returned an unexpected error. Please try again later.", - "unexpected": "An unexpected error occurred" + "unexpected": "An unexpected error occurred", + "offline": "Check your connection and try again." }, "clock": { "timeUp": "Time is up.", diff --git a/src/locales/es.json b/src/locales/es.json index df08042f8..e8cb839f3 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2438,7 +2438,8 @@ "unavailable": "{{provider}} no está disponible por ahora. Inténtalo más tarde.", "safety": "{{provider}} bloqueó esta solicitud por seguridad. Prueba a reformularla.", "unexpectedProvider": "El proveedor de IA devolvió un error inesperado. Inténtalo más tarde.", - "unexpected": "Se produjo un error inesperado" + "unexpected": "Se produjo un error inesperado", + "offline": "Comprueba tu conexión e inténtalo de nuevo." }, "clock": { "timeUp": "Se acabó el tiempo.", diff --git a/src/locales/hu.json b/src/locales/hu.json index a04bc316f..1f92c6b44 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2438,7 +2438,8 @@ "unavailable": "{{provider}}: átmenetileg nem érhető el. Próbáld újra később.", "safety": "{{provider}}: biztonsági okokból elutasította a kérést. Próbáld másképp megfogalmazni.", "unexpectedProvider": "Az MI-szolgáltató váratlan hibát adott. Próbáld újra később.", - "unexpected": "Váratlan hiba történt" + "unexpected": "Váratlan hiba történt", + "offline": "Ellenőrizd a kapcsolatot, és próbáld újra." }, "clock": { "timeUp": "Lejárt az idő.", diff --git a/src/test/auth/AuthProvider.behavior.test.tsx b/src/test/auth/AuthProvider.behavior.test.tsx index e825a72e4..a05e24866 100644 --- a/src/test/auth/AuthProvider.behavior.test.tsx +++ b/src/test/auth/AuthProvider.behavior.test.tsx @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const { getSessionMock, + getUserMock, onAuthStateChangeMock, signInWithOAuthMock, signInWithOtpMock, @@ -28,6 +29,7 @@ const { openExternalUrlMock, } = vi.hoisted(() => { const getSessionMock = vi.fn(); + const getUserMock = vi.fn(); const onAuthStateChangeMock = vi.fn(); const signInWithOAuthMock = vi.fn(); const signInWithOtpMock = vi.fn(); @@ -57,6 +59,7 @@ const { return { getSessionMock, + getUserMock, onAuthStateChangeMock, signInWithOAuthMock, signInWithOtpMock, @@ -88,6 +91,7 @@ vi.mock('@/lib/supabase/client', () => ({ supabase: { auth: { getSession: getSessionMock, + getUser: getUserMock, onAuthStateChange: onAuthStateChangeMock, signInWithOAuth: signInWithOAuthMock, signInWithOtp: signInWithOtpMock, @@ -133,6 +137,7 @@ vi.mock('@/lib/stores/sparring-store', () => ({ })); import { AuthProvider, useAuth } from '@/lib/supabase/AuthProvider'; +import { resetSessionExpiryCheck } from '@/lib/supabase/session-expiry'; import { crossfireStorageKey } from '@/lib/crossfire/storage'; import { useDebateStore } from '@/lib/stores/debate-store'; @@ -187,6 +192,10 @@ describe('AuthProvider behavior', () => { hydrateSessionStoreMock.mockResolvedValue(undefined); hydrateSettingsStoreMock.mockResolvedValue(undefined); hydrateSparringStoreMock.mockResolvedValue(undefined); + resetSessionExpiryCheck(); + // No status: a lookup that did not answer is an outage, so the local session stands. + // A user id here would also win currentUserId() over the session the test arranged. + getUserMock.mockResolvedValue({ data: { user: null }, error: { message: 'unavailable' } }); getSessionMock.mockResolvedValue({ data: { session: null }, error: null }); setSessionMock.mockResolvedValue({ data: { session: null }, error: null }); exchangeCodeForSessionMock.mockResolvedValue({ data: { session: null }, error: null }); @@ -196,6 +205,41 @@ describe('AuthProvider behavior', () => { openExternalUrlMock.mockResolvedValue(undefined); }); + it('signs out once and does not load data when the user lookup is refused', async () => { + getSessionMock.mockResolvedValue({ + data: { session: { user: { id: 'user_1' }, access_token: 'token' } }, + error: null, + }); + getUserMock.mockResolvedValue({ data: { user: null }, error: { status: 403, message: 'Forbidden' } }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('false'); + }); + + expect(screen.getByTestId('user')).toHaveTextContent('none'); + expect(signOutMock).toHaveBeenCalledTimes(1); + expect(signOutMock).toHaveBeenCalledWith({ scope: 'local' }); + expect(hydrateSessionStoreMock).not.toHaveBeenCalled(); + expect(hydrateSettingsStoreMock).not.toHaveBeenCalled(); + expect(hydrateSparringStoreMock).not.toHaveBeenCalled(); + + await act(async () => { + callbackRef.current?.('SIGNED_IN', { user: { id: 'user_1' }, access_token: 'token' }); + }); + + expect(signOutMock).toHaveBeenCalledTimes(1); + expect(hydrateSessionStoreMock).not.toHaveBeenCalled(); + expect(hydrateSettingsStoreMock).not.toHaveBeenCalled(); + expect(hydrateSparringStoreMock).not.toHaveBeenCalled(); + expect(screen.getByTestId('user')).toHaveTextContent('none'); + }); + it('hydrates stores when an initial session exists', async () => { getSessionMock.mockResolvedValue({ data: { session: { user: { id: 'user_1' }, access_token: 'token' } }, diff --git a/src/test/auth/session-expiry.test.ts b/src/test/auth/session-expiry.test.ts index bce80c2f0..2472bf37a 100644 --- a/src/test/auth/session-expiry.test.ts +++ b/src/test/auth/session-expiry.test.ts @@ -69,6 +69,30 @@ describe('a refused session', () => { expect(sessionExpiredNotice()).toBe(false); }); + it('signs out immediately when Auth itself refuses the user, without refreshing', async () => { + const auth = fakeAuth(null); + await expect(verifySessionAfterRejection(auth, Date.now, { userLookup: true })).resolves.toBe('expired'); + expect(auth.refreshSession).not.toHaveBeenCalled(); + expect(auth.signOut).toHaveBeenCalledTimes(1); + expect(auth.signOut).toHaveBeenCalledWith({ scope: 'local' }); + await expect(verifySessionAfterRejection(auth, Date.now, { userLookup: true })).resolves.toBe('expired'); + expect(auth.signOut).toHaveBeenCalledTimes(1); + }); + + it('backs off after an outage instead of retrying immediately', async () => { + let now = 1_000_000; + const auth = fakeAuth(new AuthRetryableFetchError('Service Unavailable', 503)); + + await expect(verifySessionAfterRejection(auth, () => now)).resolves.toBe('kept'); + now += 999; + await expect(verifySessionAfterRejection(auth, () => now)).resolves.toBe('skipped'); + now += 2; + await expect(verifySessionAfterRejection(auth, () => now)).resolves.toBe('kept'); + + expect(auth.refreshSession).toHaveBeenCalledTimes(2); + expect(auth.signOut).not.toHaveBeenCalled(); + }); + 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); diff --git a/src/test/components/WebTelemetry.test.tsx b/src/test/components/WebTelemetry.test.tsx new file mode 100644 index 000000000..b8702b23c --- /dev/null +++ b/src/test/components/WebTelemetry.test.tsx @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { componentFromModule, loadOptionalComponent } from '@/components/optional-telemetry'; + +function Marker(): null { + return null; +} + +describe('optional telemetry modules', () => { + it('does not throw when a preload resolves to undefined', async () => { + expect(componentFromModule(undefined, 'Analytics').name).toBe('HiddenTelemetry'); + const loaded = await loadOptionalComponent(() => Promise.resolve(undefined), 'Analytics'); + expect(loaded.default.name).toBe('HiddenTelemetry'); + }); + + it('uses the named export when the chunk arrives', async () => { + const loaded = await loadOptionalComponent( + () => Promise.resolve({ Analytics: Marker }), + 'Analytics', + ); + expect(loaded.default).toBe(Marker); + }); + + it('resolves a fallback when the chunk rejects', async () => { + const loaded = await loadOptionalComponent(() => Promise.reject(new TypeError('Failed to fetch')), 'SpeedInsights'); + expect(loaded.default.name).toBe('HiddenTelemetry'); + }); +}); diff --git a/supabase/functions/_shared/entitlements.test.ts b/supabase/functions/_shared/entitlements.test.ts index 834816564..74654009c 100644 --- a/supabase/functions/_shared/entitlements.test.ts +++ b/supabase/functions/_shared/entitlements.test.ts @@ -6,7 +6,7 @@ for (const [key, value] of Object.entries({ SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", })) Deno.env.set(key, value); -const { getSubscriptionInfo, checkRateLimit } = await import("./entitlements.ts"); +const { getSubscriptionInfo, checkRateLimit, getConfiguredDailyLimit, checkUserBanned } = await import("./entitlements.ts"); const { MODEL_QUOTA_UNITS, WEEKLY_AI_BUDGET_USD } = await import("./usage-limits.ts"); function json(body: unknown, status = 200): Response { @@ -21,9 +21,10 @@ interface Routes { user_settings?: unknown; user_roles?: unknown; subscriptions?: unknown; - rate_limits?: unknown; + rate_limits?: unknown | ((url: URL) => Response); quota?: unknown; onQuota?: (body: Record) => void; + requests?: string[]; } async function withSupabase(routes: Routes, run: () => Promise): Promise { @@ -33,7 +34,15 @@ async function withSupabase(routes: Routes, run: () => Promise): Promise { }); }); +Deno.test("configured daily limits prefer the account row, then the global row", async () => { + const requests: string[] = []; + const limits = (url: URL) => { + if (url.searchParams.get("user_id") === "eq.user-1") return json({ limit_value: 12 }); + if (url.searchParams.get("user_id") === "is.null") return json({ limit_value: 7 }); + return json(null); + }; + await withSupabase({ requests, rate_limits: limits }, async () => { + assertEquals(await getConfiguredDailyLimit("user-1", 5), 12, "per-user row"); + }); + assertEquals(requests.some((query) => query.includes("updated_at")), false, "never orders by updated_at"); + assertEquals(requests.some((query) => query.includes("order=created_at.desc")), true, "orders by created_at"); + + await withSupabase({ + rate_limits: (url: URL) => url.searchParams.get("user_id") === "is.null" ? json({ limit_value: 7 }) : json(null), + }, async () => { + assertEquals(await getConfiguredDailyLimit("user-1", 5), 7, "global row"); + }); + + await withSupabase({ rate_limits: null }, async () => { + assertEquals(await getConfiguredDailyLimit("user-1", 5), 5, "fallback"); + }); +}); + +Deno.test("a rate_limits lookup error is logged and does not invent a limit", async () => { + const warnings: string[] = []; + const original = console.warn; + console.warn = (...args: unknown[]) => { + warnings.push(args.map((part) => typeof part === "string" ? part : JSON.stringify(part)).join(" ")); + }; + try { + await withSupabase({ + rate_limits: () => json({ message: "column rate_limits.updated_at does not exist", code: "42703" }, 400), + }, async () => { + assertEquals(await getConfiguredDailyLimit("user-1", 5), 5, "fallback after the error"); + }); + } finally { + console.warn = original; + } + assertEquals(warnings.some((line) => line.includes("[RATE LIMIT]") && line.includes("updated_at")), true, "server warning"); + assertEquals(warnings.some((line) => line.includes("user-1")), false, "warning omits the user id"); +}); + +Deno.test("a user with no role row is not banned and the lookup does not demand one object", async () => { + let accept = ""; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: Request | URL | string, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(String(input), init); + if (request.url.includes("/rest/v1/user_roles")) { + accept = request.headers.get("Accept") ?? request.headers.get("accept") ?? ""; + return json([]); + } + throw new Error(`Unexpected request: ${request.url}`); + }) as typeof fetch; + try { + assertEquals(await checkUserBanned("user-1"), false, "not banned"); + } finally { + globalThis.fetch = originalFetch; + } + assertEquals(accept.includes("vnd.pgrst.object"), false, "Accept must not require a single object"); +}); + Deno.test("a model heavier than the whole daily limit is not told to wait", async () => { const smallLimit = { ...proUser, daily_limit_override: 3 }; await withSupabase({ user_settings: smallLimit, quota: [{ allowed: false, current_count: 0, denied_by: "daily" }] }, async () => { diff --git a/supabase/functions/_shared/entitlements.ts b/supabase/functions/_shared/entitlements.ts index 97ed7f5d3..5773642c9 100644 --- a/supabase/functions/_shared/entitlements.ts +++ b/supabase/functions/_shared/entitlements.ts @@ -38,11 +38,13 @@ export interface SubscriptionInfo { } export async function checkUserBanned(userId: string): Promise { + // maybeSingle: a user with no role row is not banned. .single() asks PostgREST + // for exactly one object and logs a 406 when the row is absent. const { data } = await supabase .from("user_roles") .select("role") .eq("user_id", userId) - .single(); + .maybeSingle(); return data?.role === "banned"; } @@ -124,28 +126,35 @@ export async function getSubscriptionInfo(userId: string): Promise { - const { data: userLimit } = await supabase +async function readRateLimit(userId: string | null, limitType: string): Promise { + // Production rate_limits has id, user_id, limit_type, limit_value, created_at. + // Ordering by updated_at is a 400, and the client used to ignore that error, + // so every per-account and global override was silently dropped. + const query = supabase .from("rate_limits") .select("limit_value") - .eq("user_id", userId) - .eq("limit_type", limitType) - .order("updated_at", { ascending: false }) + .eq("limit_type", limitType); + const scoped = userId === null ? query.is("user_id", null) : query.eq("user_id", userId); + const { data, error } = await scoped + .order("created_at", { ascending: false }) .limit(1) .maybeSingle(); - if (typeof userLimit?.limit_value === "number") return userLimit.limit_value; + if (error) { + console.warn("[RATE LIMIT] rate_limits lookup failed:", error.message); + return null; + } + return typeof data?.limit_value === "number" ? data.limit_value : null; +} + +export async function getConfiguredDailyLimit(userId: string, fallbackLimit: number, limitType = "daily_requests"): Promise { + const userLimit = await readRateLimit(userId, limitType); + if (userLimit !== null) return userLimit; - const { data: globalLimit } = await supabase - .from("rate_limits") - .select("limit_value") - .is("user_id", null) - .eq("limit_type", limitType) - .order("updated_at", { ascending: false }) - .limit(1) - .maybeSingle(); + const globalLimit = await readRateLimit(null, limitType); + if (globalLimit !== null) return globalLimit; - return typeof globalLimit?.limit_value === "number" ? globalLimit.limit_value : fallbackLimit; + return fallbackLimit; } export async function checkRateLimit(userId: string | null, model?: ModelSelection): Promise { diff --git a/supabase/functions/_shared/stripe.ts b/supabase/functions/_shared/stripe.ts new file mode 100644 index 000000000..81594d782 --- /dev/null +++ b/supabase/functions/_shared/stripe.ts @@ -0,0 +1,30 @@ +// The esm.sh build `stripe@14.10.0?target=deno` imports +// deno.land/std@0.177.1/node/process.ts. On import that polyfill registers a +// macrotask callback, and the callback calls Deno.core.runMicrotasks(). The +// Edge Runtime rejects that call ("Deno.core.runMicrotasks() is not supported") +// after the handler has already sent its response. Checkout and account +// deletion still succeed; the log line is the polyfill, not the Stripe call. +// +// npm:stripe resolves the package "deno" export, which is the worker build. +// That build speaks fetch and does not install the std nextTick callback. +// createFetchHttpClient() keeps every request on fetch even if a future +// default changes. + +import Stripe from "npm:stripe@14.10.0"; + +const STRIPE_API_VERSION = "2023-10-16"; + +export function createEdgeStripe(secretKey: string, apiUrl?: string | null): Stripe { + const endpoint = apiUrl ? new URL(apiUrl) : null; + return new Stripe(secretKey, { + apiVersion: STRIPE_API_VERSION, + httpClient: Stripe.createFetchHttpClient(), + ...(endpoint && { + host: endpoint.hostname, + port: endpoint.port || (endpoint.protocol === "http:" ? "80" : "443"), + protocol: endpoint.protocol.replace(":", "") as "http" | "https", + }), + }); +} + +export { Stripe }; diff --git a/supabase/functions/_shared/stripe_test.ts b/supabase/functions/_shared/stripe_test.ts new file mode 100644 index 000000000..06e23f080 --- /dev/null +++ b/supabase/functions/_shared/stripe_test.ts @@ -0,0 +1,19 @@ +import { assertEquals, assertThrows } from "jsr:@std/assert@1.0.19"; +import { createEdgeStripe, Stripe } from "./stripe.ts"; + +Deno.test("the edge Stripe client is the fetch build, not the Node http client", () => { + const fetchClient = Stripe.createFetchHttpClient(); + assertEquals(fetchClient.getClientName(), "fetch"); + assertThrows( + () => Stripe.createNodeHttpClient(), + Error, + "createFetchHttpClient", + ); + + const stripe = createEdgeStripe("sk_test_example", "http://127.0.0.1:12111"); + const fields = stripe as unknown as { getApiField(key: string): unknown }; + assertEquals(fields.getApiField("host"), "127.0.0.1"); + assertEquals(fields.getApiField("protocol"), "http"); + const httpClient = fields.getApiField("httpClient") as { getClientName(): string }; + assertEquals(httpClient.getClientName(), "fetch"); +}); diff --git a/supabase/functions/account/index.ts b/supabase/functions/account/index.ts index 5b416fc30..33e5f201b 100644 --- a/supabase/functions/account/index.ts +++ b/supabase/functions/account/index.ts @@ -11,8 +11,8 @@ // subscriptions are canceled immediately, without a refund. If Stripe cannot be // reached, nothing is deleted and the user can try again. -import Stripe from "https://esm.sh/stripe@14.10.0?target=deno"; import { createAdminClient, HttpError, jsonError, requireUser } from "../_shared/auth.ts"; +import { createEdgeStripe } from "../_shared/stripe.ts"; import { getCorsHeaders } from "../_shared/cors.ts"; export type AccountUser = { id: string; email?: string | null }; @@ -40,7 +40,7 @@ export function deletionConfirmation(user: AccountUser) { const ENDED_SUBSCRIPTION_STATUSES = new Set(["canceled", "incomplete_expired"]); export type BillingApi = { - searchCustomers: (userId: string) => AsyncIterable<{ id: string; deleted?: boolean; metadata?: Record | null }>; + searchCustomers: (userId: string) => AsyncIterable<{ id: string; deleted?: boolean | void; metadata?: Record | null }>; listOpenCheckouts: (customerId: string) => AsyncIterable<{ id: string }>; expireCheckout: (sessionId: string) => Promise; listSubscriptions: (customerId: string) => AsyncIterable<{ id: string; status: string }>; @@ -178,17 +178,7 @@ function liveDeps(): AccountDeps { const secretKey = Deno.env.get("STRIPE_SECRET_KEY"); if (!secretKey) throw billingError; // Only the local full-stack gate sets this, to reach its fake Stripe. - const apiUrl = Deno.env.get("STRIPE_API_URL"); - const endpoint = apiUrl ? new URL(apiUrl) : null; - const stripe = new Stripe(secretKey, { - apiVersion: "2023-10-16", - httpClient: Stripe.createFetchHttpClient(), - ...(endpoint && { - host: endpoint.hostname, - port: endpoint.port || (endpoint.protocol === "http:" ? "80" : "443"), - protocol: endpoint.protocol.replace(":", "") as "http" | "https", - }), - }); + const stripe = createEdgeStripe(secretKey, Deno.env.get("STRIPE_API_URL")); try { return await stopBillingFor({ searchCustomers: (user) => stripe.customers.search({ query: `metadata['user_id']:'${user}'`, limit: 100 }), diff --git a/supabase/functions/admin/index.ts b/supabase/functions/admin/index.ts index 8035bc910..554faec18 100644 --- a/supabase/functions/admin/index.ts +++ b/supabase/functions/admin/index.ts @@ -788,11 +788,13 @@ export async function handleRequest(req: Request): Promise { const { data, error } = await supabase.rpc("get_active_rate_limits"); if (error) { - // Fallback to direct query if RPC doesn't exist yet - const { data: fallbackData } = await supabase + console.warn("[ADMIN] get_active_rate_limits failed:", error.message); + // Same column set the RPC returns. updated_at is not on this table. + const { data: fallbackData, error: fallbackError } = await supabase .from("rate_limits") - .select("*") - .order("updated_at", { ascending: false }); + .select("id, user_id, limit_type, limit_value, created_at") + .order("created_at", { ascending: false }); + if (fallbackError) console.warn("[ADMIN] rate_limits lookup failed:", fallbackError.message); return new Response(JSON.stringify({ limits: fallbackData || [] }), { headers: { ...corsHeaders, "Content-Type": "application/json" }, diff --git a/supabase/functions/ai-stream/handler_test.ts b/supabase/functions/ai-stream/handler_test.ts index 82863f45a..e0f827c96 100644 --- a/supabase/functions/ai-stream/handler_test.ts +++ b/supabase/functions/ai-stream/handler_test.ts @@ -72,6 +72,7 @@ Deno.test("streaming Ask forwards the shared prompt and returns provider SSE", a }), { headers: { "Content-Type": "application/json" } }); } if (url.pathname.includes("/rest/v1/rate_limits")) { + if ((url.searchParams.get("order") ?? "").includes("updated_at")) throw new Error(`rate_limits must not order by updated_at: ${url.search}`); return new Response(JSON.stringify({ limit_value: 50 }), { headers: { "Content-Type": "application/json" }, }); diff --git a/supabase/functions/ai-stream/index_test.ts b/supabase/functions/ai-stream/index_test.ts index 20a218519..0c5e5fae5 100644 --- a/supabase/functions/ai-stream/index_test.ts +++ b/supabase/functions/ai-stream/index_test.ts @@ -95,7 +95,10 @@ async function runScenario(scenario: Scenario) { grace_period_ends_at: null, }); } - if (url.pathname.includes("/rest/v1/rate_limits")) return jsonResponse({ limit_value: 50 }); + if (url.pathname.includes("/rest/v1/rate_limits")) { + if ((url.searchParams.get("order") ?? "").includes("updated_at")) throw new Error(`rate_limits must not order by updated_at: ${url.search}`); + return jsonResponse({ limit_value: 50 }); + } if (/\/rest\/v1\/rpc\/consume_(daily|ai)_quota$/.test(url.pathname)) { return jsonResponse([{ allowed: true, current_count: 1 }]); } diff --git a/supabase/functions/ai/debate-context.test.ts b/supabase/functions/ai/debate-context.test.ts index d7632247c..8ff565c67 100644 --- a/supabase/functions/ai/debate-context.test.ts +++ b/supabase/functions/ai/debate-context.test.ts @@ -72,7 +72,10 @@ function install(options: { stream?: boolean; tables?: typeof tables; reply?: un if (url.pathname.includes("/rest/v1/user_roles")) return jsonResponse({ role: "user" }); if (url.pathname.includes("/rest/v1/user_settings")) return jsonResponse({ subscription_tier: "trial", daily_limit_override: 50, trial_ends_at: "2099-01-01T00:00:00.000Z", grace_period_ends_at: null }); if (url.pathname.includes("/rest/v1/subscriptions")) return jsonResponse([]); - if (url.pathname.includes("/rest/v1/rate_limits")) return jsonResponse({ limit_value: 50 }); + if (url.pathname.includes("/rest/v1/rate_limits")) { + if ((url.searchParams.get("order") ?? "").includes("updated_at")) throw new Error(`rate_limits must not order by updated_at: ${url.search}`); + return jsonResponse({ limit_value: 50 }); + } if (/\/rest\/v1\/rpc\/consume_(daily|ai)_quota$/.test(url.pathname)) return jsonResponse([{ allowed: true, current_count: 1 }]); if (url.pathname.includes("/rest/v1/global_prompts")) return jsonResponse({ prompt_text: null }); if (url.pathname.includes("/rest/v1/ai_requests")) return new Response(null, { status: 201 }); diff --git a/supabase/functions/ai/generate-honesty_test.ts b/supabase/functions/ai/generate-honesty_test.ts index c9ba178ce..3a00a56bf 100644 --- a/supabase/functions/ai/generate-honesty_test.ts +++ b/supabase/functions/ai/generate-honesty_test.ts @@ -78,6 +78,7 @@ Deno.test("generate marks every contention when research has no citable sources" }); } if (url.pathname.includes("/rest/v1/rate_limits")) { + if ((url.searchParams.get("order") ?? "").includes("updated_at")) throw new Error(`rate_limits must not order by updated_at: ${url.search}`); return jsonResponse({ limit_value: 50 }); } if (/\/rest\/v1\/rpc\/consume_(daily|ai)_quota$/.test(url.pathname)) { diff --git a/supabase/functions/ai/handler-research.test.ts b/supabase/functions/ai/handler-research.test.ts index 6c830da2d..93f8c699c 100644 --- a/supabase/functions/ai/handler-research.test.ts +++ b/supabase/functions/ai/handler-research.test.ts @@ -198,7 +198,10 @@ Deno.test("factoids handler carries Exa filters and consulted-source metadata ac grace_period_ends_at: null, }); } - if (url.pathname.includes("/rest/v1/rate_limits")) return jsonResponse({ limit_value: 50 }); + if (url.pathname.includes("/rest/v1/rate_limits")) { + if ((url.searchParams.get("order") ?? "").includes("updated_at")) throw new Error(`rate_limits must not order by updated_at: ${url.search}`); + return jsonResponse({ limit_value: 50 }); + } if (/\/rest\/v1\/rpc\/consume_(daily|ai)_quota$/.test(url.pathname)) { return jsonResponse([{ allowed: true, current_count: 1 }]); } @@ -336,7 +339,10 @@ Deno.test("Ask marks only surviving URL and research context as available", asyn grace_period_ends_at: null, }); } - if (url.pathname.includes("/rest/v1/rate_limits")) return jsonResponse({ limit_value: 50 }); + if (url.pathname.includes("/rest/v1/rate_limits")) { + if ((url.searchParams.get("order") ?? "").includes("updated_at")) throw new Error(`rate_limits must not order by updated_at: ${url.search}`); + return jsonResponse({ limit_value: 50 }); + } if (/\/rest\/v1\/rpc\/consume_(daily|ai)_quota$/.test(url.pathname)) { return jsonResponse([{ allowed: true, current_count: 1 }]); } @@ -468,7 +474,10 @@ function supabaseTestResponse(url: URL): Response | undefined { grace_period_ends_at: null, }); if (url.pathname.includes("/rest/v1/subscriptions")) return jsonResponse([{ status: "active", current_period_end: "2099-01-01T00:00:00.000Z" }]); - if (url.pathname.includes("/rest/v1/rate_limits")) return jsonResponse({ limit_value: 50 }); + if (url.pathname.includes("/rest/v1/rate_limits")) { + if ((url.searchParams.get("order") ?? "").includes("updated_at")) throw new Error(`rate_limits must not order by updated_at: ${url.search}`); + return jsonResponse({ limit_value: 50 }); + } if (/\/rest\/v1\/rpc\/consume_(daily|ai)_quota$/.test(url.pathname)) return jsonResponse([{ allowed: true, current_count: 1 }]); if (url.pathname.includes("/rest/v1/global_prompts")) return jsonResponse({ prompt_text: null }); if (url.pathname.includes("/rest/v1/ai_requests")) return new Response(null, { status: 201 }); diff --git a/supabase/functions/ai/provider-errors_test.ts b/supabase/functions/ai/provider-errors_test.ts index 1cf6a2445..14ced004c 100644 --- a/supabase/functions/ai/provider-errors_test.ts +++ b/supabase/functions/ai/provider-errors_test.ts @@ -15,7 +15,10 @@ Deno.test("AI JSON and SSE failures never expose provider response bodies", asyn if (url.pathname.endsWith("/user_roles")) return Response.json({ role: "user" }); if (url.pathname.endsWith("/user_settings")) return Response.json({ subscription_tier: "trial", daily_limit_override: 50, trial_ends_at: "2099-01-01", grace_period_ends_at: null }); if (url.pathname.endsWith("/subscriptions")) return Response.json([]); - if (url.pathname.endsWith("/rate_limits")) return Response.json({ limit_value: 50 }); + if (url.pathname.endsWith("/rate_limits")) { + if ((url.searchParams.get("order") ?? "").includes("updated_at")) throw new Error(`rate_limits must not order by updated_at: ${url.search}`); + return Response.json({ limit_value: 50 }); + } if (url.pathname.endsWith("/global_prompts")) return Response.json({ prompt_text: null }); if (/\/consume_(daily|ai)_quota$/.test(url.pathname)) return Response.json([{ allowed: true, current_count: 1 }]); if (url.pathname.endsWith("/ai_requests")) return new Response(null, { status: 201 }); diff --git a/supabase/functions/ai/runtime.ts b/supabase/functions/ai/runtime.ts index d5cbf1d00..337cfb2d5 100644 --- a/supabase/functions/ai/runtime.ts +++ b/supabase/functions/ai/runtime.ts @@ -47,7 +47,7 @@ export async function getGlobalPrompt(feature: string): Promise { .select("prompt_text") .eq("feature", feature) .eq("is_active", true) - .single(); + .maybeSingle(); if (featurePrompt?.prompt_text) { return featurePrompt.prompt_text; @@ -59,7 +59,7 @@ export async function getGlobalPrompt(feature: string): Promise { .select("prompt_text") .eq("feature", "all") .eq("is_active", true) - .single(); + .maybeSingle(); return allPrompt?.prompt_text || null; } diff --git a/supabase/functions/ai/runtime_prompt_test.ts b/supabase/functions/ai/runtime_prompt_test.ts new file mode 100644 index 000000000..077820d84 --- /dev/null +++ b/supabase/functions/ai/runtime_prompt_test.ts @@ -0,0 +1,33 @@ +// A missing global prompt is a normal empty result. .single() turns that into +// a PostgREST 406. maybeSingle leaves it as no row. + +for (const [key, value] of Object.entries({ + SUPABASE_URL: "https://supabase.test", + SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", +})) Deno.env.set(key, value); + +const { getGlobalPrompt } = await import("./runtime.ts"); + +function assertEquals(actual: unknown, expected: unknown, message: string) { + if (actual !== expected) throw new Error(`${message}: expected ${expected}, got ${actual}`); +} + +Deno.test("a missing global prompt is null and the lookup does not demand one object", async () => { + const accepts: string[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: Request | URL | string, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(String(input), init); + if (request.url.includes("/rest/v1/global_prompts")) { + accepts.push(request.headers.get("Accept") ?? request.headers.get("accept") ?? ""); + return Response.json([]); + } + throw new Error(`Unexpected request: ${request.url}`); + }) as typeof fetch; + try { + assertEquals(await getGlobalPrompt("generate"), null, "prompt"); + } finally { + globalThis.fetch = originalFetch; + } + assertEquals(accepts.length, 2, "feature prompt, then the all prompt"); + assertEquals(accepts.every((accept) => !accept.includes("vnd.pgrst.object")), true, "Accept must not require a single object"); +}); diff --git a/supabase/functions/ai/stream.test.ts b/supabase/functions/ai/stream.test.ts index 43a01ae6b..724702fb7 100644 --- a/supabase/functions/ai/stream.test.ts +++ b/supabase/functions/ai/stream.test.ts @@ -66,7 +66,10 @@ function install(replies: ProviderReply[]): Harness { if (url.pathname.includes("/rest/v1/user_roles")) return jsonResponse({ role: "user" }); if (url.pathname.includes("/rest/v1/user_settings")) return jsonResponse({ subscription_tier: "trial", daily_limit_override: 50, trial_ends_at: "2099-01-01T00:00:00.000Z", grace_period_ends_at: null }); if (url.pathname.includes("/rest/v1/subscriptions")) return jsonResponse([]); - if (url.pathname.includes("/rest/v1/rate_limits")) return jsonResponse({ limit_value: 50 }); + if (url.pathname.includes("/rest/v1/rate_limits")) { + if ((url.searchParams.get("order") ?? "").includes("updated_at")) throw new Error(`rate_limits must not order by updated_at: ${url.search}`); + return jsonResponse({ limit_value: 50 }); + } if (/\/rest\/v1\/rpc\/consume_(daily|ai)_quota$/.test(url.pathname)) return jsonResponse([{ allowed: true, current_count: 1 }]); if (url.pathname.includes("/rest/v1/global_prompts")) return jsonResponse({ prompt_text: null }); if (url.pathname.includes("/rest/v1/ai_requests")) { diff --git a/supabase/functions/ai/voice.test.ts b/supabase/functions/ai/voice.test.ts index a06977c80..55be07a23 100644 --- a/supabase/functions/ai/voice.test.ts +++ b/supabase/functions/ai/voice.test.ts @@ -44,7 +44,10 @@ function install(reply: unknown): Harness { if (url.pathname.includes("/rest/v1/user_roles")) return jsonResponse({ role: "user" }); if (url.pathname.includes("/rest/v1/user_settings")) return jsonResponse({ subscription_tier: "trial", daily_limit_override: 50, trial_ends_at: "2099-01-01T00:00:00.000Z", grace_period_ends_at: null }); if (url.pathname.includes("/rest/v1/subscriptions")) return jsonResponse([]); - if (url.pathname.includes("/rest/v1/rate_limits")) return jsonResponse({ limit_value: 50 }); + if (url.pathname.includes("/rest/v1/rate_limits")) { + if ((url.searchParams.get("order") ?? "").includes("updated_at")) throw new Error(`rate_limits must not order by updated_at: ${url.search}`); + return jsonResponse({ limit_value: 50 }); + } if (/\/rest\/v1\/rpc\/consume_(daily|ai)_quota$/.test(url.pathname)) return jsonResponse([{ allowed: true, current_count: 1 }]); if (url.pathname.includes("/rest/v1/rpc/sparring_request_started_at")) return jsonResponse(new Date().toISOString()); if (url.pathname.includes("/rest/v1/rpc/claim_sparring_score_session")) { diff --git a/supabase/functions/create-checkout/checkout-security.ts b/supabase/functions/create-checkout/checkout-security.ts index 42509a2ed..9c8028bfb 100644 --- a/supabase/functions/create-checkout/checkout-security.ts +++ b/supabase/functions/create-checkout/checkout-security.ts @@ -1,6 +1,8 @@ type StripeCustomerLike = { id: string; - deleted?: boolean; + // The npm Stripe types mark a live customer's `deleted` as `void`, and a + // removed customer's as `true`. Accept both. + deleted?: boolean | void; metadata?: Record | null; }; @@ -35,5 +37,5 @@ export function findCustomerOwnedByUser( customers: StripeCustomerLike[], userId: string, ): string | null { - return customers.find((customer) => !customer.deleted && customer.metadata?.user_id === userId)?.id ?? null; + return customers.find((customer) => customer.deleted !== true && customer.metadata?.user_id === userId)?.id ?? null; } diff --git a/supabase/functions/create-checkout/index.ts b/supabase/functions/create-checkout/index.ts index c46b018fd..da2abbe85 100644 --- a/supabase/functions/create-checkout/index.ts +++ b/supabase/functions/create-checkout/index.ts @@ -2,8 +2,8 @@ // Creates a Checkout Session for subscription purchase. import { createClient, type User } from "https://esm.sh/@supabase/supabase-js@2.114.0"; -import Stripe from "https://esm.sh/stripe@14.10.0?target=deno"; import { HttpError, jsonError, requireUser } from "../_shared/auth.ts"; +import { createEdgeStripe, Stripe } from "../_shared/stripe.ts"; import { getCorsHeaders } from "../_shared/cors.ts"; import { findCustomerOwnedByUser, @@ -22,10 +22,7 @@ if (!STRIPE_SECRET_KEY) throw new Error("Missing required env: STRIPE_SECRET_KEY if (!SUPABASE_URL) throw new Error("Missing required env: SUPABASE_URL"); if (!SUPABASE_SERVICE_ROLE_KEY) throw new Error("Missing required env: SUPABASE_SERVICE_ROLE_KEY"); -const stripe = new Stripe(STRIPE_SECRET_KEY, { - apiVersion: "2023-10-16", - httpClient: Stripe.createFetchHttpClient(), -}); +const stripe = createEdgeStripe(STRIPE_SECRET_KEY); const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY); @@ -47,7 +44,7 @@ class AppError extends Error { } function isDeletedPrice(price: Stripe.Price | Stripe.DeletedPrice): price is Stripe.DeletedPrice { - return "deleted" in price && price.deleted; + return "deleted" in price && price.deleted === true; } function isActiveRecurringPrice(price: Stripe.Price | Stripe.DeletedPrice): price is Stripe.Price { diff --git a/supabase/functions/create-portal/index.ts b/supabase/functions/create-portal/index.ts index dba897452..7f2df950b 100644 --- a/supabase/functions/create-portal/index.ts +++ b/supabase/functions/create-portal/index.ts @@ -2,8 +2,8 @@ // Generates a portal URL so users can manage their subscription. import { createClient, type User } from "https://esm.sh/@supabase/supabase-js@2.114.0"; -import Stripe from "https://esm.sh/stripe@14.10.0?target=deno"; import { HttpError, jsonError, requireUser } from "../_shared/auth.ts"; +import { createEdgeStripe } from "../_shared/stripe.ts"; import { getCorsHeaders } from "../_shared/cors.ts"; import { findCustomerOwnedByUser, @@ -19,10 +19,7 @@ if (!STRIPE_SECRET_KEY) throw new Error("Missing required env: STRIPE_SECRET_KEY if (!SUPABASE_URL) throw new Error("Missing required env: SUPABASE_URL"); if (!SUPABASE_SERVICE_ROLE_KEY) throw new Error("Missing required env: SUPABASE_SERVICE_ROLE_KEY"); -const stripe = new Stripe(STRIPE_SECRET_KEY, { - apiVersion: "2023-10-16", - httpClient: Stripe.createFetchHttpClient(), -}); +const stripe = createEdgeStripe(STRIPE_SECRET_KEY); const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY); diff --git a/supabase/functions/deno.json b/supabase/functions/deno.json index 24e57470a..d863ca78e 100644 --- a/supabase/functions/deno.json +++ b/supabase/functions/deno.json @@ -1,4 +1,5 @@ { + "nodeModulesDir": "auto", "compilerOptions": { "lib": [ "deno.ns", diff --git a/supabase/functions/stripe-webhook/index.ts b/supabase/functions/stripe-webhook/index.ts index 2f910cd2e..5b8f40bdc 100644 --- a/supabase/functions/stripe-webhook/index.ts +++ b/supabase/functions/stripe-webhook/index.ts @@ -2,7 +2,7 @@ // Processes Stripe events and syncs subscription state to the database. import { createClient } from "https://esm.sh/@supabase/supabase-js@2.114.0"; -import Stripe from "https://esm.sh/stripe@14.10.0?target=deno"; +import { createEdgeStripe, Stripe } from "../_shared/stripe.ts"; import { duplicateWebhookResponse, processWebhookEvent, @@ -22,17 +22,7 @@ if (!SUPABASE_URL) throw new Error("Missing required env: SUPABASE_URL"); if (!SUPABASE_SERVICE_ROLE_KEY) throw new Error("Missing required env: SUPABASE_SERVICE_ROLE_KEY"); // Only the local full-stack gate sets this, to reach its fake Stripe. -const stripeApiUrl = Deno.env.get("STRIPE_API_URL"); -const stripeEndpoint = stripeApiUrl ? new URL(stripeApiUrl) : null; -const stripe = new Stripe(STRIPE_SECRET_KEY, { - apiVersion: "2023-10-16", - httpClient: Stripe.createFetchHttpClient(), - ...(stripeEndpoint && { - host: stripeEndpoint.hostname, - port: stripeEndpoint.port || (stripeEndpoint.protocol === "http:" ? "80" : "443"), - protocol: stripeEndpoint.protocol.replace(":", "") as "http" | "https", - }), -}); +const stripe = createEdgeStripe(STRIPE_SECRET_KEY, Deno.env.get("STRIPE_API_URL")); const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY); @@ -73,7 +63,7 @@ Deno.serve(async (req) => { console.log(`[WEBHOOK] Received event: ${event.type} (${event.id})`); try { - const outcome = await processWebhookEvent(event, { + const outcome = await processWebhookEvent(event as Stripe.Event & { data: { object: { id: string } } }, { // Claim and completion run on the database clock; see claim_webhook_event. claim: async (id, type) => { const { data, error } = await supabase.rpc("claim_webhook_event", { p_id: id, p_type: type }); diff --git a/supabase/functions/voice/index_test.ts b/supabase/functions/voice/index_test.ts index 2d208b05a..bddbdb4fd 100644 --- a/supabase/functions/voice/index_test.ts +++ b/supabase/functions/voice/index_test.ts @@ -67,6 +67,9 @@ async function withVoice(options: Options, run: (harness: Harness) => Promise Date: Thu, 22 Oct 2026 16:07:16 +0000 Subject: [PATCH 2/2] Renumber rate_limits migration after production 20261022170000 --- ..._at.sql => 20261022180000_rate_limits_order_by_created_at.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename supabase/migrations/{20261022140000_rate_limits_order_by_created_at.sql => 20261022180000_rate_limits_order_by_created_at.sql} (100%) diff --git a/supabase/migrations/20261022140000_rate_limits_order_by_created_at.sql b/supabase/migrations/20261022180000_rate_limits_order_by_created_at.sql similarity index 100% rename from supabase/migrations/20261022140000_rate_limits_order_by_created_at.sql rename to supabase/migrations/20261022180000_rate_limits_order_by_created_at.sql