From 6c83f974785156b56c278d387952168d112b7787 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Wed, 14 May 2025 23:26:50 +0800 Subject: [PATCH] fix: local api route fixes (#35642) * fix: local api route fixes * fix build * fix tests * address feedback * remove unused debug --- .../CronJobs/CronJobScheduleSection.tsx | 7 +- apps/studio/lib/api/apiAuthenticate.test.ts | 266 +----------------- apps/studio/lib/api/apiAuthenticate.ts | 118 +------- apps/studio/lib/api/supabaseClient.test.ts | 49 ---- apps/studio/lib/api/supabaseClient.ts | 26 -- apps/studio/lib/gotrue.ts | 3 +- apps/studio/middleware.ts | 4 - apps/studio/pages/api/ai/docs.ts | 150 ---------- .../pages/api/ai/edge-function/complete.ts | 8 +- apps/studio/pages/api/ai/onboarding/design.ts | 11 +- apps/studio/pages/api/ai/sql/check-api-key.ts | 5 +- apps/studio/pages/api/ai/sql/complete.ts | 8 +- apps/studio/pages/api/ai/sql/cron.ts | 3 +- apps/studio/pages/api/ai/sql/debug.ts | 80 ------ apps/studio/pages/api/ai/sql/generate-v2.ts | 107 ------- apps/studio/pages/api/ai/sql/generate-v3.ts | 8 +- apps/studio/pages/api/ai/sql/suggest.ts | 101 ------- apps/studio/pages/api/ai/sql/title.ts | 3 +- 18 files changed, 67 insertions(+), 890 deletions(-) delete mode 100644 apps/studio/lib/api/supabaseClient.test.ts delete mode 100644 apps/studio/lib/api/supabaseClient.ts delete mode 100644 apps/studio/pages/api/ai/docs.ts delete mode 100644 apps/studio/pages/api/ai/sql/debug.ts delete mode 100644 apps/studio/pages/api/ai/sql/generate-v2.ts delete mode 100644 apps/studio/pages/api/ai/sql/suggest.ts diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx index 2a8ddeba408..5dc322de01a 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx @@ -6,6 +6,7 @@ import { useDebounce } from 'use-debounce' import { useCompletion } from 'ai/react' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { useCronTimezoneQuery } from 'data/database-cron-jobs/database-cron-timezone-query' +import { constructHeaders } from 'data/fetchers' import { BASE_PATH } from 'lib/constants' import { Accordion_Shadcn_, @@ -78,7 +79,11 @@ export const CronJobScheduleSection = ({ form, supportsSeconds }: CronJobSchedul useEffect(() => { if (useNaturalLanguage && debouncedValue) { - generateCronSyntax(debouncedValue) + constructHeaders().then((headers) => + generateCronSyntax(debouncedValue, { + headers: { Authorization: headers.get('Authorization') ?? '' }, + }) + ) return () => stop() } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/apps/studio/lib/api/apiAuthenticate.test.ts b/apps/studio/lib/api/apiAuthenticate.test.ts index 419ed672512..e11fac9a09c 100644 --- a/apps/studio/lib/api/apiAuthenticate.test.ts +++ b/apps/studio/lib/api/apiAuthenticate.test.ts @@ -1,6 +1,5 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { apiAuthenticate } from './apiAuthenticate' -import { readOnly } from './supabaseClient' const mocks = vi.hoisted(() => { return { @@ -11,25 +10,11 @@ const mocks = vi.hoisted(() => { }, error: null, }), - getIdentity: vi.fn().mockReturnValue({ - identity: null, - error: null, - }), - getAuth0Id: vi.fn(), } }) -// Mock dependencies -vi.mock('./supabaseClient', () => ({ - readOnly: { - from: vi.fn(), - }, -})) - vi.mock('lib/gotrue', () => ({ getAuthUser: mocks.getAuthUser, - getIdentity: mocks.getIdentity, - getAuth0Id: mocks.getAuth0Id, })) describe('apiAuthenticate', () => { @@ -51,26 +36,12 @@ describe('apiAuthenticate', () => { }, error: null, }) - mocks.getIdentity.mockReturnValue({ - identity: null, - error: null, - }) - }) - - it('should return error when request is not available', async () => { - const result = await apiAuthenticate(null as any, mockRes) - expect(result).toStrictEqual({ error: new Error('Request is not available') }) - }) - - it('should return error when response is not available', async () => { - const result = await apiAuthenticate(mockReq, null as any) - expect(result).toStrictEqual({ error: new Error('Response is not available') }) }) it('should return error when authorization token is missing', async () => { const reqWithoutToken = { ...mockReq, headers: {} } const result = await apiAuthenticate(reqWithoutToken, mockRes) - expect(result).toStrictEqual({ error: { name: 'Error', message: 'missing access token' } }) + expect(result).toStrictEqual({ error: new Error('missing access token') }) }) it('should return error when auth user fetch fails', async () => { @@ -80,239 +51,16 @@ describe('apiAuthenticate', () => { }) const result = await apiAuthenticate(mockReq, mockRes) - expect(result).toStrictEqual({ error: { name: 'Error', message: 'Auth failed' } }) - }) - - it('should handle identity error', async () => { - mocks.getIdentity.mockReturnValue({ - identity: null, - error: new Error('Identity error'), - }) - - const result = await apiAuthenticate(mockReq, mockRes) - expect(result).toStrictEqual({ error: { name: 'Error', message: 'Identity error' } }) - }) - - it('should set auth0 id when identity provider is present', async () => { - mocks.getIdentity.mockReturnValue({ - identity: { - provider: 'auth0', - id: 'auth0-id', - }, - error: null, - }) - mocks.getAuth0Id.mockReturnValue('auth0-user-id') - - // Mock user query - vi.mocked(readOnly.from).mockReturnValue({ - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - single: vi - .fn() - .mockResolvedValue({ data: { id: 'test-user-id', primary_email: 'test@example.com' } }), - } as any) - - const result = await apiAuthenticate(mockReq, mockRes) - expect(result).toStrictEqual({ - id: 'test-user-id', - primary_email: 'test@example.com', - }) - expect(mocks.getAuth0Id).toHaveBeenCalledWith('auth0', 'auth0-id') - }) - - it('should return user when user_id_supabase is present', async () => { - // Mock identity to return auth0 provider - mocks.getIdentity.mockReturnValue({ - identity: { - provider: 'auth0', - id: 'auth0-id', - }, - error: null, - }) - mocks.getAuth0Id.mockReturnValue('auth0-user-id') - - // Mock user query to return a user with auth0_id - vi.mocked(readOnly.from).mockReturnValue({ - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - single: vi.fn().mockResolvedValue({ - data: { - id: 'supabase-user-id', - auth0_id: 'auth0-user-id', - primary_email: 'test@example.com', - }, - }), - } as any) - - const result = await apiAuthenticate(mockReq, mockRes) - expect(result).toStrictEqual({ - id: 'supabase-user-id', - auth0_id: 'auth0-user-id', - primary_email: 'test@example.com', - }) + expect(result).toStrictEqual({ error: new Error('Auth failed') }) }) it('should return error when user does not exist', async () => { - vi.mocked(readOnly.from).mockReturnValue({ - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - single: vi.fn().mockResolvedValue({ data: null }), - } as any) + mocks.getAuthUser.mockResolvedValue({ + user: null, + error: null, + }) const result = await apiAuthenticate(mockReq, mockRes) expect(result).toStrictEqual({ error: new Error('The user does not exist') }) }) - - it('should check organization permissions when orgSlug is provided', async () => { - const reqWithOrg = { - ...mockReq, - query: { slug: 'test-org' }, - } - - // Mock user query - vi.mocked(readOnly.from).mockReturnValueOnce({ - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - single: vi - .fn() - .mockResolvedValue({ data: { id: 'test-user-id', primary_email: 'test@example.com' } }), - } as any) - - // Mock organization query - vi.mocked(readOnly.from).mockReturnValueOnce({ - select: vi.fn().mockReturnThis(), - match: vi.fn().mockReturnThis(), - single: vi.fn().mockResolvedValue({ data: { id: 'org-id' } }), - } as any) - - // Mock member check - vi.mocked(readOnly.from).mockReturnValueOnce({ - select: vi.fn().mockReturnThis(), - match: vi.fn().mockReturnThis(), - single: vi.fn().mockResolvedValue({ data: { id: 'member-id' }, status: 200 }), - } as any) - - const result = await apiAuthenticate(reqWithOrg, mockRes) - expect(result).toStrictEqual({ - id: 'test-user-id', - primary_email: 'test@example.com', - }) - }) - - it('should return error when user lacks organization permissions', async () => { - const reqWithOrg = { - ...mockReq, - query: { slug: 'test-org' }, - } - - // Mock user query - vi.mocked(readOnly.from).mockReturnValueOnce({ - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - single: vi - .fn() - .mockResolvedValue({ data: { id: 'test-user-id', primary_email: 'test@example.com' } }), - } as any) - - // Mock organization query - vi.mocked(readOnly.from).mockReturnValueOnce({ - select: vi.fn().mockReturnThis(), - match: vi.fn().mockReturnThis(), - single: vi.fn().mockResolvedValue({ data: { id: 'org-id' } }), - } as any) - - // Mock member check failure - vi.mocked(readOnly.from).mockReturnValueOnce({ - select: vi.fn().mockReturnThis(), - match: vi.fn().mockReturnThis(), - single: vi.fn().mockRejectedValue(new Error('Permission denied')), - } as any) - - const result = await apiAuthenticate(reqWithOrg, mockRes) - expect(result).toStrictEqual({ - error: { name: 'Error', message: 'The user does not have permission' }, - }) - }) - - it('should handle unknown errors gracefully', async () => { - mocks.getAuthUser.mockRejectedValue(new Error('Unexpected error')) - - const result = await apiAuthenticate(mockReq, mockRes) - expect(result).toStrictEqual({ error: { name: 'Error', message: 'Unexpected error' } }) - }) - - it('should get organization from project reference', async () => { - const reqWithProject = { - ...mockReq, - query: { ref: 'test-project' }, - } - - // Mock user query - vi.mocked(readOnly.from).mockReturnValueOnce({ - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - single: vi - .fn() - .mockResolvedValue({ data: { id: 'test-user-id', primary_email: 'test@example.com' } }), - } as any) - - // Mock project query - vi.mocked(readOnly.from).mockReturnValueOnce({ - select: vi.fn().mockReturnThis(), - match: vi.fn().mockReturnThis(), - single: vi.fn().mockResolvedValue({ data: { organization_id: 'org-id' } }), - } as any) - - // Mock member check - vi.mocked(readOnly.from).mockReturnValueOnce({ - select: vi.fn().mockReturnThis(), - match: vi.fn().mockReturnThis(), - single: vi.fn().mockResolvedValue({ data: { id: 'member-id' }, status: 200 }), - } as any) - - const result = await apiAuthenticate(reqWithProject, mockRes) - expect(result).toStrictEqual({ - id: 'test-user-id', - primary_email: 'test@example.com', - }) - }) - - it('should use organization_id from projectRef in member check', async () => { - const reqWithProject = { - ...mockReq, - query: { ref: 'project-xyz' }, - } - - // Mock user query - vi.mocked(readOnly.from).mockReturnValueOnce({ - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - single: vi - .fn() - .mockResolvedValue({ data: { id: 'user-123', primary_email: 'user@example.com' } }), - } as any) - - // Mock project query to return a specific organization_id - vi.mocked(readOnly.from).mockReturnValueOnce({ - select: vi.fn().mockReturnThis(), - match: vi.fn().mockReturnThis(), - single: vi.fn().mockResolvedValue({ data: { organization_id: 'org-abc' } }), - } as any) - - // Spy on the match function for the member check - const matchSpy = vi.fn().mockReturnThis() - vi.mocked(readOnly.from).mockReturnValueOnce({ - select: vi.fn().mockReturnThis(), - match: matchSpy, - single: vi.fn().mockResolvedValue({ data: { id: 'member-1' }, status: 200 }), - } as any) - - const result = await apiAuthenticate(reqWithProject, mockRes) - expect(result).toStrictEqual({ - id: 'user-123', - primary_email: 'user@example.com', - }) - // Ensure the member check used the org id from the project lookup - expect(matchSpy).toHaveBeenCalledWith({ organization_id: 'org-abc', user_id: 'user-123' }) - }) }) diff --git a/apps/studio/lib/api/apiAuthenticate.ts b/apps/studio/lib/api/apiAuthenticate.ts index 392fc407ce9..bcca2e361f4 100644 --- a/apps/studio/lib/api/apiAuthenticate.ts +++ b/apps/studio/lib/api/apiAuthenticate.ts @@ -1,44 +1,30 @@ +import { getAuthUser } from 'lib/gotrue' import type { NextApiRequest, NextApiResponse } from 'next' -import { readOnly } from './supabaseClient' -import type { SupaResponse, User } from 'types' -import { getAuth0Id, getAuthUser, getIdentity } from 'lib/gotrue' +import type { ResponseError, SupaResponse, User } from 'types' /** * Use this method on api routes to check if user is authenticated and having required permissions. * This method can only be used from the server side. * Member permission is mandatory whenever orgSlug/projectRef query param exists * @param {NextApiRequest} req - * @param {NextApiResponse} res - * @param {Object} config requireUserDetail: bool, requireOwner: bool + * @param {NextApiResponse} _res * * @returns {Object} * user null, with error and description if not authenticated or not enough permissions */ export async function apiAuthenticate( req: NextApiRequest, - res: NextApiResponse + _res: NextApiResponse ): Promise> { - if (!req) { - return { error: new Error('Request is not available') } - } - - if (!res) { - return { error: new Error('Response is not available') } - } - - const { slug: orgSlug, ref: projectRef } = req.query try { - const user = await fetchUser(req, res) + const user = await fetchUser(req) if (!user) { return { error: new Error('The user does not exist') } } - if (orgSlug || projectRef) await checkMemberPermission(req, user) - return user - } catch (error: any) { - console.error('Error at apiAuthenticate', error) - return { error: { name: 'Error', message: error.message ?? 'unknown' } } + } catch (error) { + return { error: error as ResponseError } } } @@ -46,93 +32,19 @@ export async function apiAuthenticate( * @returns * user with only id prop or detail object. It depends on requireUserDetail config */ -async function fetchUser(req: NextApiRequest, res: NextApiResponse): Promise { - let user_id_supabase = null - let user_id_auth0 = null - let gotrue_id = null - let email = null - - const token = req.headers.authorization +async function fetchUser(req: NextApiRequest): Promise { + const token = req.headers.authorization?.replace('Bearer ', '') if (!token) { throw new Error('missing access token') } - let { user: gotrue_user, error: authError } = await getAuthUser(token) - if (authError) { - throw authError - } - if (gotrue_user !== null) { - gotrue_id = gotrue_user?.id - email = gotrue_user.email - - let { identity, error } = getIdentity(gotrue_user) - if (error) throw error - if (identity?.provider !== undefined) { - user_id_auth0 = getAuth0Id(identity?.provider, identity?.id) - } + const { user, error } = await getAuthUser(token) + if (error) { + throw error } - if (user_id_supabase) { - return { - id: user_id_supabase, - primary_email: email, - } + if (!user) { + throw new Error('The user does not exist') } - const query = readOnly.from('users').select( - ` - id, auth0_id, primary_email, username, first_name, last_name, mobile, is_alpha_user - ` - ) - - const { data } = await query.eq('gotrue_id', gotrue_id).single() - return data -} - -async function checkMemberPermission(req: NextApiRequest, user: any) { - const org = await getOrganization(req) - if (!org) { - throw new Error('User organization does not exist') - } - - try { - const response = await readOnly - .from('members') - .select('id') - .match({ organization_id: org.id, user_id: user.id }) - .single() - - if (!response || response.status != 200) { - throw new Error('The user does not have permission') - } - return true - } catch (error) { - throw new Error('The user does not have permission') - } -} - -async function getOrganization(req: NextApiRequest) { - const { slug: orgSlug, ref: projectRef } = req.query - if (!orgSlug && !projectRef) { - throw new Error('Not enough info to check user permissions') - } - - if (orgSlug) { - const { data } = await readOnly - .from('organizations') - .select('id') - .match({ slug: orgSlug }) - .single() - return { id: data.id } - } - - if (projectRef) { - const { data } = await readOnly - .from('projects') - .select('organization_id') - .match({ ref: projectRef }) - .single() - return { id: data.organization_id } - } - - return null + return user } diff --git a/apps/studio/lib/api/supabaseClient.test.ts b/apps/studio/lib/api/supabaseClient.test.ts deleted file mode 100644 index 212290ece17..00000000000 --- a/apps/studio/lib/api/supabaseClient.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { readOnly } from './supabaseClient' - -vi.mock('lib/constants', () => ({ - IS_PLATFORM: true, -})) - -const readOnlyErrMessage = 'Read only error' - -vi.mock('@supabase/supabase-js', () => ({ - createClient: vi.fn(() => ({ - from: vi.fn(() => ({ - insert: () => { - throw readOnlyErrMessage - }, - delete: () => { - throw readOnlyErrMessage - }, - update: () => { - throw readOnlyErrMessage - }, - })), - rpc: () => { - throw readOnlyErrMessage - }, - })), -})) - -describe('supabaseClient', () => { - it('should be defined', () => { - expect(readOnly).toBeDefined() - }) - - it('should throw on inserts', () => { - expect(() => readOnly.from('').insert({})).toThrowError() - }) - - it('should throw on deletes', () => { - expect(() => readOnly.from('').delete({})).toThrowError() - }) - - it('should throw on updates', () => { - expect(() => readOnly.from('').update({})).toThrowError() - }) - - it('should throw on rpc', () => { - expect(() => readOnly.rpc({})).toThrowError() - }) -}) diff --git a/apps/studio/lib/api/supabaseClient.ts b/apps/studio/lib/api/supabaseClient.ts deleted file mode 100644 index 16d1ddbd092..00000000000 --- a/apps/studio/lib/api/supabaseClient.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { createClient } from '@supabase/supabase-js' -import { IS_PLATFORM } from '../constants' - -let readOnly: any - -if (IS_PLATFORM) { - readOnly = createClient(process.env.READ_ONLY_URL ?? '', process.env.READ_ONLY_API_KEY ?? '') - const readOnlyErrMessage = Error('This client is for read-only actions. Use readWrite instead.') - - // overwrites function calls - // for readOnly - readOnly.from('').insert = () => { - throw readOnlyErrMessage - } - readOnly.from('').delete = () => { - throw readOnlyErrMessage - } - readOnly.from('').update = () => { - throw readOnlyErrMessage - } - readOnly.rpc = () => { - throw readOnlyErrMessage - } -} - -export { readOnly } diff --git a/apps/studio/lib/gotrue.ts b/apps/studio/lib/gotrue.ts index 1315fbbd042..0c96b31a04d 100644 --- a/apps/studio/lib/gotrue.ts +++ b/apps/studio/lib/gotrue.ts @@ -1,4 +1,5 @@ -import { getAccessToken, gotrueClient, type User } from 'common' +import { getAccessToken, type User } from 'common/auth' +import { gotrueClient } from 'common/gotrue' export const auth = gotrueClient export { getAccessToken } diff --git a/apps/studio/middleware.ts b/apps/studio/middleware.ts index f8b87218861..e40965fb868 100644 --- a/apps/studio/middleware.ts +++ b/apps/studio/middleware.ts @@ -7,16 +7,12 @@ export const config = { // [Joshen] Return 404 for all next.js API endpoints EXCEPT the ones we use in hosted: const HOSTED_SUPPORTED_API_URLS = [ - '/ai/sql/suggest', - '/ai/sql/generate-v2', '/ai/sql/generate-v3', '/ai/edge-function/complete', '/ai/onboarding/design', '/ai/sql/complete', '/ai/sql/title', - '/ai/sql/debug', '/ai/sql/cron', - '/ai/docs', '/get-ip-address', '/get-utc-time', '/edge-functions/test', diff --git a/apps/studio/pages/api/ai/docs.ts b/apps/studio/pages/api/ai/docs.ts deleted file mode 100644 index b2e45876a61..00000000000 --- a/apps/studio/pages/api/ai/docs.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { SupabaseClient } from '@supabase/supabase-js' -import { ApplicationError, UserError, clippy } from 'ai-commands/edge' -import { NextRequest } from 'next/server' -import OpenAI from 'openai' - -export const config = { - runtime: 'edge', - /* To avoid OpenAI errors, restrict to the Vercel Edge Function regions that - overlap with the OpenAI API regions. - - Reference for Vercel regions: https://vercel.com/docs/edge-network/regions#region-list - Reference for OpenAI regions: https://help.openai.com/en/articles/5347006-openai-api-supported-countries-and-territories - */ - regions: [ - 'arn1', - 'bom1', - 'cdg1', - 'cle1', - 'cpt1', - 'dub1', - 'fra1', - 'gru1', - 'hnd1', - 'iad1', - 'icn1', - 'kix1', - 'lhr1', - 'pdx1', - 'sfo1', - 'sin1', - 'syd1', - ], -} - -const openAiKey = process.env.OPENAI_API_KEY -const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL as string -const supabaseServiceKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY as string - -export default async function handler(req: NextRequest) { - if (!openAiKey) { - return new Response( - JSON.stringify({ - error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.', - }), - { - status: 500, - headers: { 'Content-Type': 'application/json' }, - } - ) - } - - if (!supabaseUrl) { - return new Response( - JSON.stringify({ - error: - 'No NEXT_PUBLIC_SUPABASE_URL set. Create this environment variable to use AI features.', - }), - { - status: 500, - headers: { 'Content-Type': 'application/json' }, - } - ) - } - - if (!supabaseServiceKey) { - return new Response( - JSON.stringify({ - error: - 'No NEXT_PUBLIC_SUPABASE_ANON_KEY set. Create this environment variable to use AI features.', - }), - { - status: 500, - headers: { 'Content-Type': 'application/json' }, - } - ) - } - - const { method } = req - - switch (method) { - case 'POST': - return handlePost(req) - default: - return new Response( - JSON.stringify({ data: null, error: { message: `Method ${method} Not Allowed` } }), - { - status: 405, - headers: { 'Content-Type': 'application/json', Allow: 'POST' }, - } - ) - } -} - -async function handlePost(request: NextRequest) { - const openai = new OpenAI({ apiKey: openAiKey }) - - const body = await (request.json() as Promise<{ - messages: { content: string; role: 'user' | 'assistant' }[] - }>) - - const { messages } = body - - if (!messages) { - throw new UserError('Missing messages in request data') - } - - const supabaseClient = new SupabaseClient(supabaseUrl, supabaseServiceKey) - - try { - const response = await clippy(openai, supabaseClient, messages) - - // Proxy the streamed SSE response from OpenAI - return new Response(response.body, { - headers: { - 'Content-Type': 'text/event-stream', - }, - }) - } catch (error: unknown) { - console.error(error) - if (error instanceof UserError) { - return new Response( - JSON.stringify({ - error: error.message, - data: error.data, - }), - { - status: 400, - headers: { 'Content-Type': 'application/json' }, - } - ) - } else if (error instanceof ApplicationError) { - // Print out application errors with their additional data - console.error(`${error.message}: ${JSON.stringify(error.data)}`) - } else { - // Print out unexpected errors as is to help with debugging - console.error(error) - } - - // TODO: include more response info in debug environments - return new Response( - JSON.stringify({ - error: 'There was an error processing your request', - }), - { - status: 500, - headers: { 'Content-Type': 'application/json' }, - } - ) - } -} diff --git a/apps/studio/pages/api/ai/edge-function/complete.ts b/apps/studio/pages/api/ai/edge-function/complete.ts index afbf3aac4b4..193c3643fd8 100644 --- a/apps/studio/pages/api/ai/edge-function/complete.ts +++ b/apps/studio/pages/api/ai/edge-function/complete.ts @@ -2,6 +2,7 @@ import { openai } from '@ai-sdk/openai' import pgMeta from '@supabase/pg-meta' import { streamText } from 'ai' import { executeSql } from 'data/sql/execute-sql-query' +import apiWrapper from 'lib/api/apiWrapper' import { NextApiRequest, NextApiResponse } from 'next' import { getTools } from '../sql/tools' @@ -9,7 +10,7 @@ export const maxDuration = 30 const openAiKey = process.env.OPENAI_API_KEY const pgMetaSchemasList = pgMeta.schemas.list() -export default async function handler(req: NextApiRequest, res: NextApiResponse) { +async function handler(req: NextApiRequest, res: NextApiResponse) { if (!openAiKey) { return new Response( JSON.stringify({ @@ -35,6 +36,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } } +const wrapper = (req: NextApiRequest, res: NextApiResponse) => + apiWrapper(req, res, handler, { withAuth: true }) + +export default wrapper + async function handlePost(req: NextApiRequest, res: NextApiResponse) { try { const { completionMetadata, projectRef, connectionString, includeSchemaMetadata } = req.body diff --git a/apps/studio/pages/api/ai/onboarding/design.ts b/apps/studio/pages/api/ai/onboarding/design.ts index b39dbe88c8f..2dc29da88c0 100644 --- a/apps/studio/pages/api/ai/onboarding/design.ts +++ b/apps/studio/pages/api/ai/onboarding/design.ts @@ -1,7 +1,7 @@ import { openai } from '@ai-sdk/openai' -import { streamText } from 'ai' +import { streamText, tool } from 'ai' +import apiWrapper from 'lib/api/apiWrapper' import { NextApiRequest, NextApiResponse } from 'next' -import { tool } from 'ai' import { z } from 'zod' const openAiKey = process.env.OPENAI_API_KEY @@ -46,7 +46,7 @@ const getTools = () => { } } -export default async function handler(req: NextApiRequest, res: NextApiResponse) { +async function handler(req: NextApiRequest, res: NextApiResponse) { if (!openAiKey) { return res.status(400).json({ error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.', @@ -64,6 +64,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } } +const wrapper = (req: NextApiRequest, res: NextApiResponse) => + apiWrapper(req, res, handler, { withAuth: true }) + +export default wrapper + async function handlePost(req: NextApiRequest, res: NextApiResponse) { const { messages } = req.body diff --git a/apps/studio/pages/api/ai/sql/check-api-key.ts b/apps/studio/pages/api/ai/sql/check-api-key.ts index 177d4739861..a4bead534b5 100644 --- a/apps/studio/pages/api/ai/sql/check-api-key.ts +++ b/apps/studio/pages/api/ai/sql/check-api-key.ts @@ -1,7 +1,10 @@ import apiWrapper from 'lib/api/apiWrapper' import { NextApiRequest, NextApiResponse } from 'next' -export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) +const wrapper = (req: NextApiRequest, res: NextApiResponse) => + apiWrapper(req, res, handler, { withAuth: true }) + +export default wrapper async function handler(req: NextApiRequest, res: NextApiResponse) { const { method } = req diff --git a/apps/studio/pages/api/ai/sql/complete.ts b/apps/studio/pages/api/ai/sql/complete.ts index f1f4929dc89..9f51e9f2ff3 100644 --- a/apps/studio/pages/api/ai/sql/complete.ts +++ b/apps/studio/pages/api/ai/sql/complete.ts @@ -2,6 +2,7 @@ import { openai } from '@ai-sdk/openai' import pgMeta from '@supabase/pg-meta' import { streamText } from 'ai' import { executeSql } from 'data/sql/execute-sql-query' +import apiWrapper from 'lib/api/apiWrapper' import { NextApiRequest, NextApiResponse } from 'next' import { getTools } from '../sql/tools' @@ -9,7 +10,7 @@ export const maxDuration = 30 const openAiKey = process.env.OPENAI_API_KEY const pgMetaSchemasList = pgMeta.schemas.list() -export default async function handler(req: NextApiRequest, res: NextApiResponse) { +async function handler(req: NextApiRequest, res: NextApiResponse) { if (!openAiKey) { return new Response( JSON.stringify({ @@ -150,3 +151,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }) } } + +const wrapper = (req: NextApiRequest, res: NextApiResponse) => + apiWrapper(req, res, handler, { withAuth: true }) + +export default wrapper diff --git a/apps/studio/pages/api/ai/sql/cron.ts b/apps/studio/pages/api/ai/sql/cron.ts index d4469c054b8..9e67862507d 100644 --- a/apps/studio/pages/api/ai/sql/cron.ts +++ b/apps/studio/pages/api/ai/sql/cron.ts @@ -54,6 +54,7 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) { } } -const wrapper = (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) +const wrapper = (req: NextApiRequest, res: NextApiResponse) => + apiWrapper(req, res, handler, { withAuth: true }) export default wrapper diff --git a/apps/studio/pages/api/ai/sql/debug.ts b/apps/studio/pages/api/ai/sql/debug.ts deleted file mode 100644 index a1dfb99c8ea..00000000000 --- a/apps/studio/pages/api/ai/sql/debug.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { ContextLengthError, EmptySqlError, debugSql } from 'ai-commands' -import apiWrapper from 'lib/api/apiWrapper' -import { NextApiRequest, NextApiResponse } from 'next' -import { OpenAI } from 'openai' - -const openAiKey = process.env.OPENAI_API_KEY -const openai = new OpenAI({ apiKey: openAiKey }) - -async function handler(req: NextApiRequest, res: NextApiResponse) { - if (!openAiKey) { - return res.status(500).json({ - error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.', - }) - } - - const { method } = req - - switch (method) { - case 'POST': - return handlePost(req, res) - default: - res.setHeader('Allow', ['POST']) - res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) - } -} - -export async function handlePost(req: NextApiRequest, res: NextApiResponse) { - const { - body: { errorMessage, sql, entityDefinitions }, - } = req - - try { - const result = await debugSql(openai, errorMessage, sql, entityDefinitions) - return res.json(result) - } catch (error) { - if (error instanceof Error) { - console.error(`AI SQL debugging failed: ${error.message}`) - - const hasEntityDefinitions = entityDefinitions !== undefined && entityDefinitions.length > 0 - - if (error instanceof ContextLengthError) { - // If there are more entity definitions than the SQL provided, attribute the - // error to the database metadata - if (hasEntityDefinitions) { - const definitionsLength = entityDefinitions.reduce( - (sum: number, def: string) => sum + def.length, - 0 - ) - if (definitionsLength > sql.length) { - return res.status(400).json({ - error: - 'Your database metadata is too large for Supabase AI to ingest. Try disabling database metadata in AI settings.', - }) - } - } - // Otherwise attribute the error to the SQL being too large - return res.status(400).json({ - error: - 'Your SQL query is too large for Supabase AI to ingest. Try splitting it into smaller queries.', - }) - } - - if (error instanceof EmptySqlError) { - res.status(400).json({ - error: 'Unable to debug SQL. No fix identified for the error.', - }) - } - } else { - console.log(`Unknown error: ${error}`) - } - - return res.status(500).json({ - error: 'There was an unknown error debugging the SQL snippet. Please try again.', - }) - } -} - -const wrapper = (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) - -export default wrapper diff --git a/apps/studio/pages/api/ai/sql/generate-v2.ts b/apps/studio/pages/api/ai/sql/generate-v2.ts deleted file mode 100644 index c3e8e8d7fdf..00000000000 --- a/apps/studio/pages/api/ai/sql/generate-v2.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { StreamingTextResponse } from 'ai' -import { chatRlsPolicy, chatSql } from 'ai-commands/edge' -import { SupportedAssistantEntities } from 'components/ui/AIAssistantPanel/AIAssistant.types' -import { DatabasePoliciesData } from 'data/database-policies/database-policies-query' -import { NextRequest } from 'next/server' -import OpenAI from 'openai' - -export const config = { - runtime: 'edge', - /* To avoid OpenAI errors, restrict to the Vercel Edge Function regions that - overlap with the OpenAI API regions. - - Reference for Vercel regions: https://vercel.com/docs/edge-network/regions#region-list - Reference for OpenAI regions: https://help.openai.com/en/articles/5347006-openai-api-supported-countries-and-territories - */ - regions: [ - 'arn1', - 'bom1', - 'cdg1', - 'cle1', - 'cpt1', - 'dub1', - 'fra1', - 'gru1', - 'hnd1', - 'iad1', - 'icn1', - 'kix1', - 'lhr1', - 'pdx1', - 'sfo1', - 'sin1', - 'syd1', - ], -} - -const openAiKey = process.env.OPENAI_API_KEY - -export default async function handler(req: NextRequest) { - if (!openAiKey) { - return new Response( - JSON.stringify({ - error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.', - }), - { - status: 500, - headers: { 'Content-Type': 'application/json' }, - } - ) - } - - const { method } = req - - switch (method) { - case 'POST': - return handlePost(req) - default: - return new Response( - JSON.stringify({ data: null, error: { message: `Method ${method} Not Allowed` } }), - { - status: 405, - headers: { 'Content-Type': 'application/json', Allow: 'POST' }, - } - ) - } -} - -async function handlePost(request: NextRequest) { - const openai = new OpenAI({ apiKey: openAiKey }) - - const body = await (request.json() as Promise<{ - context?: SupportedAssistantEntities - messages: { content: string; role: 'user' | 'assistant' }[] - existingSql?: string - entityDefinitions: string[] - existingPolicies?: DatabasePoliciesData - }>) - - const { messages, existingSql, entityDefinitions, context, existingPolicies } = body - - try { - if (context === 'rls-policies') { - const stream = await chatRlsPolicy( - openai, - messages, - entityDefinitions, - existingPolicies ?? [], - existingSql - ) - return new StreamingTextResponse(stream) - } else { - const stream = await chatSql(openai, messages, existingSql, entityDefinitions, context) - return new StreamingTextResponse(stream) - } - } catch (error) { - if (error instanceof Error) { - console.error(`AI SQL generation-v2 failed: ${error.message}`) - } else { - console.error(`AI SQL generation-v2 failed: ${error}`) - } - - return new Response(JSON.stringify({ error: 'There was an error processing your request' }), { - status: 500, - headers: { 'Content-Type': 'application/json' }, - }) - } -} diff --git a/apps/studio/pages/api/ai/sql/generate-v3.ts b/apps/studio/pages/api/ai/sql/generate-v3.ts index 5f011678557..cbc45514430 100644 --- a/apps/studio/pages/api/ai/sql/generate-v3.ts +++ b/apps/studio/pages/api/ai/sql/generate-v3.ts @@ -4,13 +4,14 @@ import { streamText } from 'ai' import { NextApiRequest, NextApiResponse } from 'next' import { executeSql } from 'data/sql/execute-sql-query' +import apiWrapper from 'lib/api/apiWrapper' import { getTools } from './tools' export const maxDuration = 30 const openAiKey = process.env.OPENAI_API_KEY const pgMetaSchemasList = pgMeta.schemas.list() -export default async function handler(req: NextApiRequest, res: NextApiResponse) { +async function handler(req: NextApiRequest, res: NextApiResponse) { if (!openAiKey) { return res.status(500).json({ error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.', @@ -28,6 +29,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } } +const wrapper = (req: NextApiRequest, res: NextApiResponse) => + apiWrapper(req, res, handler, { withAuth: true }) + +export default wrapper + async function handlePost(req: NextApiRequest, res: NextApiResponse) { const { messages, projectRef, connectionString, includeSchemaMetadata, schema, table } = req.body diff --git a/apps/studio/pages/api/ai/sql/suggest.ts b/apps/studio/pages/api/ai/sql/suggest.ts deleted file mode 100644 index 206d2bdce5e..00000000000 --- a/apps/studio/pages/api/ai/sql/suggest.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { StreamingTextResponse } from 'ai' -import { chatRlsPolicy } from 'ai-commands/edge' -import { DatabasePoliciesData } from 'data/database-policies/database-policies-query' -import { NextRequest } from 'next/server' -import OpenAI from 'openai' - -export const config = { - runtime: 'edge', - /* To avoid OpenAI errors, restrict to the Vercel Edge Function regions that - overlap with the OpenAI API regions. - - Reference for Vercel regions: https://vercel.com/docs/edge-network/regions#region-list - Reference for OpenAI regions: https://help.openai.com/en/articles/5347006-openai-api-supported-countries-and-territories - */ - regions: [ - 'arn1', - 'bom1', - 'cdg1', - 'cle1', - 'cpt1', - 'dub1', - 'fra1', - 'gru1', - 'hnd1', - 'iad1', - 'icn1', - 'kix1', - 'lhr1', - 'pdx1', - 'sfo1', - 'sin1', - 'syd1', - ], -} - -const openAiKey = process.env.OPENAI_API_KEY - -export default async function handler(req: NextRequest) { - if (!openAiKey) { - return new Response( - JSON.stringify({ - error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.', - }), - { - status: 500, - headers: { 'Content-Type': 'application/json' }, - } - ) - } - - const { method } = req - - switch (method) { - case 'POST': - return handlePost(req) - default: - return new Response( - JSON.stringify({ data: null, error: { message: `Method ${method} Not Allowed` } }), - { - status: 405, - headers: { 'Content-Type': 'application/json', Allow: 'POST' }, - } - ) - } -} - -async function handlePost(request: NextRequest) { - const openai = new OpenAI({ apiKey: openAiKey }) - - const body = await (request.json() as Promise<{ - messages: { content: string; role: 'user' | 'assistant' }[] - entityDefinitions: string[] - existingPolicies: DatabasePoliciesData - policyDefinition: string - }>) - - const { messages, entityDefinitions, existingPolicies, policyDefinition } = body - - try { - const stream = await chatRlsPolicy( - openai, - messages, - entityDefinitions, - existingPolicies, - policyDefinition - ) - return new StreamingTextResponse(stream) - } catch (error) { - console.error(error) - - return new Response( - JSON.stringify({ - error: 'There was an error processing your request', - }), - { - status: 500, - headers: { 'Content-Type': 'application/json' }, - } - ) - } -} diff --git a/apps/studio/pages/api/ai/sql/title.ts b/apps/studio/pages/api/ai/sql/title.ts index 6bbf73eb696..83d6f4e20d7 100644 --- a/apps/studio/pages/api/ai/sql/title.ts +++ b/apps/studio/pages/api/ai/sql/title.ts @@ -52,6 +52,7 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) { } } -const wrapper = (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) +const wrapper = (req: NextApiRequest, res: NextApiResponse) => + apiWrapper(req, res, handler, { withAuth: true }) export default wrapper