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 d4fc5e9b1..e6ff7c9ff 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 63674b0eb..b4f86a9f3 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2444,7 +2444,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 6f019eb12..a0280a9bd 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2444,7 +2444,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 2deb7952d..69405e4dd 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2444,7 +2444,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 a7156672c..7f35f213a 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 f37791e2b..e24c80e45 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 50f2ccb44..f0773f52e 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 }]); } @@ -477,7 +483,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 31d515759..e16215a6c 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