From 586ed82270544719d8c8e857ae09cee92103a2e2 Mon Sep 17 00:00:00 2001 From: Shane Date: Tue, 7 Jul 2026 12:00:46 -0500 Subject: [PATCH 01/54] content(www): update company page metrics, investors, and press (#47700) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Updated community stats: 10M+ developers, 100K+ GitHub stars, 200K+ followers, 50K+ SupaTroopers - Updated funding copy from \"over \$116 million\" to \"\$1 Billion\" - Added Supabase Series F blog post to Press section - Changed press articles grid from 3-column to 4-column to match podcasts layout - Reduced community stats font size (text-4xl → text-3xl) and added right padding to prevent overlap with dividers ## Summary by CodeRabbit ## Summary by CodeRabbit * **Bug Fixes** * Improved spacing and borders in the community stats grid for cleaner end-of-list alignment. * **Style** * Reduced the community stat number size for better readability. * Updated the Press articles layout on large screens to show more items per row. * **Content Updates** * Refreshed community stat counts for Developers, GitHub, Twitter, and Discord. * Updated “Our investors” copy to **$1 billion**. * Added **“Supabase Series F”** as the first Press article. --------- Co-authored-by: Danny White <3104761+dnywh@users.noreply.github.com> --- apps/www/data/Community.tsx | 8 ++++---- apps/www/data/Press.ts | 6 ++++++ apps/www/pages/company.tsx | 10 ++++------ 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/apps/www/data/Community.tsx b/apps/www/data/Community.tsx index 639fdc507ba..d84ad067831 100644 --- a/apps/www/data/Community.tsx +++ b/apps/www/data/Community.tsx @@ -12,7 +12,7 @@ type CommunityItem = { const data: CommunityItem[] = [ { title: 'Developers', - stat: '7,000,000+', + stat: '10,000,000+', statLabel: 'Registered developers', img: 'supabase.png', detail: () => ( @@ -30,7 +30,7 @@ const data: CommunityItem[] = [ }, { title: 'GitHub', - stat: '98,000+', + stat: '100,000+', statLabel: 'GitHub stars', img: 'github.png', invertImgDarkMode: true, @@ -42,7 +42,7 @@ const data: CommunityItem[] = [ }, { title: 'Twitter', - stat: '190,000+', + stat: '200,000+', statLabel: 'Followers', img: 'twitter.png', detail: () => ( @@ -53,7 +53,7 @@ const data: CommunityItem[] = [ }, { title: 'Discord', - stat: '47,000+', + stat: '50,000+', statLabel: 'SupaTroopers', img: 'discord.png', detail: () => ( diff --git a/apps/www/data/Press.ts b/apps/www/data/Press.ts index 598c3e712d6..30050d8324f 100644 --- a/apps/www/data/Press.ts +++ b/apps/www/data/Press.ts @@ -6,6 +6,12 @@ type PressItem = { } const data: PressItem[] = [ + { + type: 'article', + href: 'https://supabase.com/blog/supabase-series-f', + title: 'Supabase Series F', + publisher: 'Supabase', + }, { type: 'article', href: 'https://techcrunch.com/2022/05/10/supabase-raises-80m-series-b-for-its-open-source-firebase-alternative/', diff --git a/apps/www/pages/company.tsx b/apps/www/pages/company.tsx index fc298d1d9bf..544f0813e95 100644 --- a/apps/www/pages/company.tsx +++ b/apps/www/pages/company.tsx @@ -114,8 +114,7 @@ const Community = () => { key={x.title} className={` space-y-4 text-center lg:text-left - ${i !== CommunityData.length - 1 ? 'border-default lg:border-r' : ''} - ${i === 1 ? 'border-default md:border-0 lg:border-r ' : ''} + ${i !== CommunityData.length - 1 ? 'border-default lg:border-r lg:pr-4' : ''} `} >
{ />
-

{x.stat}

+

{x.stat}

{x.statLabel}

@@ -163,8 +162,7 @@ const Investors = () => { title="Our investors" paragraph={ <> - We've raised over $116 million in funding, backed by some of the world's leading - investors. + We've raised $1 billion in funding, backed by some of the world's leading investors. } /> @@ -224,7 +222,7 @@ const Press = () => {
-
+
{PressData.filter((x) => x.type == 'article').map((x) => ( Date: Wed, 8 Jul 2026 04:05:19 +1000 Subject: [PATCH 02/54] feat(studio): warn in Connect sheet when Data API is disabled (#47537) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Feature. Resolves DEPR-599. ## What is the current behavior? When the Data API is disabled (PostgREST has no exposed schemas), the Connect sheet still shows client-library setup steps for Framework and MCP modes without indicating that database queries will fail. ## What is the new behavior? When database access via the Connect instructions requires PostgREST, an inline warning appears above the steps (setup instructions remain visible): - **Framework**: warns when Data API is off; install, env vars, and auth/SSR setup still work - **MCP**: warns only when Database tools apply (selected explicitly, or by default when no feature filter is set) The warning fails open if PostgREST config cannot be loaded, and links to Data API settings via an "Enable Data API" CTA. | After | | --- | | CleanShot 2026-07-02 at 21 29
16@2x| ## Additional context - Gating logic lives in `ConnectStepsSection.utils.ts` with unit tests - Out of scope: warning when Data API is on but zero tables/schemas are exposed - Coexists with the upcoming warehouse branch's catalog warning — that lives in a separate `WarehouseCatalogPanel` for `catalog` mode only ## Summary by CodeRabbit * **New Features** * Connection setup now checks Data API enablement and conditionally shows a “Data API disabled” warning, including an action to open Data API settings. * **Bug Fixes** * Warning logic now more accurately reflects the selected connection mode and chosen feature/tool selections. * **Tests** * Added a focused test suite covering the Data API configuration decision rules and when the warning should appear. --- .../ConnectSheet/ConnectStepsSection.tsx | 36 +++++++ .../ConnectStepsSection.utils.test.ts | 98 +++++++++++++++++++ .../ConnectSheet/ConnectStepsSection.utils.ts | 20 ++++ apps/studio/hooks/misc/useIsDataApiEnabled.ts | 10 +- 4 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.utils.test.ts create mode 100644 apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.utils.ts diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx b/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx index 5eee915f9dd..717a0db9b16 100644 --- a/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx @@ -15,6 +15,10 @@ import type { StepContentProps, } from './Connect.types' import { ConnectSheetStep } from './ConnectSheetStep' +import { + shouldFetchDataApiConfig, + shouldShowDataApiDisabledWarning, +} from './ConnectStepsSection.utils' import { CopyPromptAdmonition } from './CopyPromptAdmonition' import { buildConnectionStringPooler, getConnectionStrings } from './DatabaseSettings.utils' import { getAddons } from '@/components/interfaces/Billing/Subscription/Subscription.utils' @@ -25,6 +29,7 @@ import { useSupavisorConfigurationQuery } from '@/data/database/supavisor-config import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query' import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' import { useDeploymentMode } from '@/hooks/misc/useDeploymentMode' +import { useIsDataApiEnabled } from '@/hooks/misc/useIsDataApiEnabled' import { DOCS_URL } from '@/lib/constants' import { pluckObjectFields } from '@/lib/helpers' @@ -225,6 +230,23 @@ export function ConnectStepsSection({ steps, state, projectKeys }: ConnectStepsS const showSelfHostedMcpNotice = deploymentMode.isSelfHosted && state.mode === 'mcp' + const shouldFetchDataApiStatus = shouldFetchDataApiConfig({ + mode: state.mode, + }) + const { + isEnabled: isDataApiEnabled, + isPending: isDataApiConfigPending, + isError: isDataApiConfigError, + } = useIsDataApiEnabled({ + projectRef: ref, + enabled: shouldFetchDataApiStatus, + }) + const showDataApiDisabledWarning = shouldShowDataApiDisabledWarning({ + mode: state.mode, + isDataApiEnabled, + isPending: isDataApiConfigPending, + isError: isDataApiConfigError, + }) if (steps.length === 0) return null return ( @@ -232,6 +254,20 @@ export function ConnectStepsSection({ steps, state, projectKeys }: ConnectStepsS

Connect your app

+ {showDataApiDisabledWarning && ( + + Enable Data API + , + ]} + /> + )} + {showIpv4AddonNotice && ( { + test('returns true for framework mode', () => { + expect(shouldFetchDataApiConfig({ mode: 'framework' })).toBe(true) + }) + + test.each(['direct', 'orm', 'server', 'mcp'] as const)('returns false for %s mode', (mode) => { + expect(shouldFetchDataApiConfig({ mode })).toBe(false) + }) + + test('returns false for mcp even when database tools are selected', () => { + expect(shouldFetchDataApiConfig(MCP_WITH_DATABASE_TOOLS)).toBe(false) + }) +}) + +describe('shouldShowDataApiDisabledWarning', () => { + test.each(ALL_MODES)('returns false while pending for %s mode', (mode) => { + expect( + shouldShowDataApiDisabledWarning({ + mode, + isDataApiEnabled: false, + isPending: true, + isError: false, + }) + ).toBe(false) + }) + + test('returns false when config query errored', () => { + expect( + shouldShowDataApiDisabledWarning({ + mode: 'framework', + isDataApiEnabled: false, + isPending: false, + isError: true, + }) + ).toBe(false) + }) + + test('returns true for framework when Data API is disabled', () => { + expect( + shouldShowDataApiDisabledWarning({ + mode: 'framework', + isDataApiEnabled: false, + isPending: false, + isError: false, + }) + ).toBe(true) + }) + + test.each(['direct', 'orm', 'server', 'mcp'] as const)( + 'returns false for %s when Data API is disabled', + (mode) => { + expect( + shouldShowDataApiDisabledWarning({ + mode, + isDataApiEnabled: false, + isPending: false, + isError: false, + }) + ).toBe(false) + } + ) + + test('returns false for mcp with database tools selected when Data API is disabled', () => { + expect( + shouldShowDataApiDisabledWarning({ + ...MCP_WITH_DATABASE_TOOLS, + isDataApiEnabled: false, + isPending: false, + isError: false, + }) + ).toBe(false) + }) + + test('returns false when framework mode has Data API enabled', () => { + expect( + shouldShowDataApiDisabledWarning({ + mode: 'framework', + isDataApiEnabled: true, + isPending: false, + isError: false, + }) + ).toBe(false) + }) +}) diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.utils.ts b/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.utils.ts new file mode 100644 index 00000000000..95998f7a513 --- /dev/null +++ b/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.utils.ts @@ -0,0 +1,20 @@ +import type { ConnectMode } from './Connect.types' + +export function shouldFetchDataApiConfig({ mode }: { mode: ConnectMode }): boolean { + return mode === 'framework' +} + +export function shouldShowDataApiDisabledWarning({ + mode, + isDataApiEnabled, + isPending, + isError, +}: { + mode: ConnectMode + isDataApiEnabled: boolean + isPending: boolean + isError: boolean +}): boolean { + if (isPending || isError || isDataApiEnabled) return false + return shouldFetchDataApiConfig({ mode }) +} diff --git a/apps/studio/hooks/misc/useIsDataApiEnabled.ts b/apps/studio/hooks/misc/useIsDataApiEnabled.ts index cb3662c8bd2..0e09180694f 100644 --- a/apps/studio/hooks/misc/useIsDataApiEnabled.ts +++ b/apps/studio/hooks/misc/useIsDataApiEnabled.ts @@ -6,8 +6,14 @@ import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest- * The Data API is considered enabled when the PostgREST `db_schema` config * contains at least one non-empty schema name. */ -export const useIsDataApiEnabled = ({ projectRef }: { projectRef?: string }) => { - const { data: config, ...rest } = useProjectPostgrestConfigQuery({ projectRef }) +export const useIsDataApiEnabled = ({ + projectRef, + enabled = true, +}: { + projectRef?: string + enabled?: boolean +}) => { + const { data: config, ...rest } = useProjectPostgrestConfigQuery({ projectRef }, { enabled }) const isEnabled = !!config?.db_schema?.trim() From c4c213ce3d6db0471ae009e2e0258097b387ab07 Mon Sep 17 00:00:00 2001 From: Pedro Rodrigues <44656907+Rodriguespn@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:38:21 +0100 Subject: [PATCH 03/54] feat(studio): switch dashboard assistant to remote MCP server (#47479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## I have read the [CONTRIBUTING.md]() file. YES ## What kind of change does this PR introduce? Feature / refactor. ## What is the current behavior? The dashboard assistant runs `@supabase/mcp-server-supabase` in-process over an in-memory transport (`lib/ai/supabase-mcp.ts`). ## What is the new behavior? The assistant connects to the **remote MCP server** over HTTP (`@ai-sdk/mcp`), forwarding the dashboard session token as a bearer. URL comes from `NEXT_PUBLIC_MCP_URL` with a local-dev fallback; platform-only, and Nimbus works via the same env var. * **Tool model unchanged:** UI-controlled `execute_sql` (with `needsApproval`) and `deploy_edge_function` still come from Studio; the allowlist (`TOOL_CATEGORY_MAP`) remains the gate keeping the remote's write tools away from the assistant (`read_only` is defense-in-depth). * **Attribution:** sends `x-source-name: supabase-studio` (+ `x-source-version`) → logged as `source_name`/`client_name`. * **Connection lifecycle:** the HTTP client is closed via the request's `AbortSignal` (tools execute later during streaming); `signal` is required on `getTools`/`getMcpTools`. * **Resilience:** a remote-MCP failure degrades to the remaining tools instead of failing the assistant. * **Drift protection:** relied-upon tools are typed against `keyof typeof supabaseMcpToolSchemas`, so a package bump that renames/removes one fails `pnpm typecheck`; a runtime check also warns if the deployed server returns fewer tools. * Adds unit tests for the above. ## Additional context * Verified end-to-end against a local remote MCP server with a dashboard token: `initialize` 200, tools listed, a tool executed, client closed cleanly. * The remote MCP (mgmt-api) already accepts dashboard session tokens (GoTrue-JWT auth path) — no backend change needed. `NEXT_PUBLIC_MCP_URL` must point at each env's `/mcp`. * `@supabase/mcp-server-supabase` is kept — still used by the self-hosted `/api/mcp` routes. Closes [AI-137](https://linear.app/supabase/issue/AI-137/switch-dashboard-assistant-to-remote-mcp) ## Rollout * **Rollout:** merges with `USE_REMOTE_MCP` off (in-process); flip it to `true` per environment (staging → prod → Nimbus) once each one's prerequisites land. * **Rollback:** unset `USE_REMOTE_MCP` and redeploy to fall back to the in-process client — no revert needed. ## Summary by CodeRabbit * **Bug Fixes** * Improved AI request handling so tool loading and generation clean up properly when a request is cancelled or the browser connection closes. * Added safer fallback behavior when remote tool loading fails, so AI features can continue with available tools instead of stopping entirely. * Updated remote tool access to use the current project reference and preserve the correct access headers. ## Summary by CodeRabbit * **New Features** * AI tools now connect more reliably to remote services and stop cleanly when requests end or are canceled. * Tool loading is more resilient, continuing with available tools if remote access is unavailable. * **Bug Fixes** * Improved cleanup to prevent lingering connections during SQL generation and policy workflows. * Added safer handling for remote tool changes and invalid responses. * **Tests** * Expanded automated coverage for remote tool setup, cancellation, and fallback behavior. --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/braintrust-evals.yml | 38 ++++- apps/studio/evals/assistant.eval.ts | 36 ++-- apps/studio/evals/preflight.ts | 49 ++++++ apps/studio/lib/ai/supabase-mcp.test.ts | 139 ++++++++++++++++ apps/studio/lib/ai/supabase-mcp.ts | 113 ++++++++++++- apps/studio/lib/ai/tools/index.test.ts | 86 ++++++++++ apps/studio/lib/ai/tools/index.ts | 26 ++- apps/studio/lib/ai/tools/mcp-tools.test.ts | 172 ++++++++++++++++++++ apps/studio/lib/ai/tools/mcp-tools.ts | 135 ++++++++++++--- apps/studio/lib/ai/tools/mock-tools.test.ts | 64 ++++++++ apps/studio/lib/ai/tools/mock-tools.ts | 21 ++- apps/studio/lib/api/generate-v4.test.ts | 4 + apps/studio/package.json | 1 + apps/studio/pages/api/ai/sql/generate-v4.ts | 4 + apps/studio/pages/api/ai/sql/policy.ts | 92 ++++++----- apps/studio/turbo.jsonc | 3 + 16 files changed, 888 insertions(+), 95 deletions(-) create mode 100644 apps/studio/evals/preflight.ts create mode 100644 apps/studio/lib/ai/supabase-mcp.test.ts create mode 100644 apps/studio/lib/ai/tools/index.test.ts create mode 100644 apps/studio/lib/ai/tools/mcp-tools.test.ts create mode 100644 apps/studio/lib/ai/tools/mock-tools.test.ts diff --git a/.github/workflows/braintrust-evals.yml b/.github/workflows/braintrust-evals.yml index a2f9075f913..d05093acd61 100644 --- a/.github/workflows/braintrust-evals.yml +++ b/.github/workflows/braintrust-evals.yml @@ -15,8 +15,42 @@ permissions: contents: read jobs: + # Cheap, always-on gate: verifies the eval harness can reach the MCP server + # (its one real tool, search_docs). Runs on every push/PR — no OpenAI, no full + # eval suite — so a broken MCP connection is caught early with an actionable + # message. The eval job depends on this, so evals never run against a broken + # MCP connection. + preflight: + name: Eval MCP preflight + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false + + - name: Install pnpm + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + with: + run_install: false + + - name: Use Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: '.nvmrc' + cache: 'pnpm' + + - name: Install Dependencies + run: pnpm install --frozen-lockfile + + - name: Preflight — MCP connectivity + run: cd apps/studio && pnpm evals:preflight + eval: name: Run evals + needs: preflight if: github.event_name == 'push' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-evals') && github.event.pull_request.head.repo.full_name == github.repository) runs-on: ubuntu-latest timeout-minutes: 20 @@ -43,8 +77,8 @@ jobs: - name: Use Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version-file: ".nvmrc" - cache: "pnpm" + node-version-file: '.nvmrc' + cache: 'pnpm' - name: Install Dependencies run: pnpm install --frozen-lockfile diff --git a/apps/studio/evals/assistant.eval.ts b/apps/studio/evals/assistant.eval.ts index 207594cb24a..b540b630720 100644 --- a/apps/studio/evals/assistant.eval.ts +++ b/apps/studio/evals/assistant.eval.ts @@ -31,20 +31,30 @@ Eval('Assistant', { const modelResponse = await getModel({ provider: 'openai', modelEntry }) if (modelResponse.error) throw modelResponse.error - const result = await generateAssistantResponse({ - ...modelResponse.modelParams, - messages: [ - { - id: '1', - role: 'user', - parts: [{ type: 'text', text: input.prompt }], - }, - ], - tools: await getMockTools(input.mockTables ? { list_tables: input.mockTables } : undefined), - }) + // Owns the lifecycle of the remote MCP client opened inside getMockTools: + // aborting once generation is done closes that connection. + const toolsAbortController = new AbortController() + try { + const result = await generateAssistantResponse({ + ...modelResponse.modelParams, + messages: [ + { + id: '1', + role: 'user', + parts: [{ type: 'text', text: input.prompt }], + }, + ], + tools: await getMockTools( + input.mockTables ? { list_tables: input.mockTables } : undefined, + toolsAbortController.signal + ), + }) - const finishReason = await result.finishReason - return { finishReason } + const finishReason = await result.finishReason + return { finishReason } + } finally { + toolsAbortController.abort() + } }, scores: [ toolUsageScorer, diff --git a/apps/studio/evals/preflight.ts b/apps/studio/evals/preflight.ts new file mode 100644 index 00000000000..a8889540e2c --- /dev/null +++ b/apps/studio/evals/preflight.ts @@ -0,0 +1,49 @@ +/** + * Eval preflight — MCP connectivity check. + * + * The assistant eval harness (`getMockTools`) mocks every tool except + * `search_docs`, which it sources from a real MCP server. If that connection is + * broken (endpoint down, bad/expired token, contract drift, missing package), + * evals fail deep inside a Braintrust run with an opaque per-case error. + * + * This preflight exercises the exact same path and fails fast with an + * actionable message, so a broken MCP connection is caught up front when the + * eval job runs (e.g. on push). Keep it in lockstep with how `getMockTools` + * obtains `search_docs` — if that switches to the remote client (see AI-897), + * switch this too. + */ +import { createInProcessSupabaseMCPClient } from '@/lib/ai/supabase-mcp' + +async function runPreflight() { + let client: Awaited> | undefined + + try { + client = await createInProcessSupabaseMCPClient({ + accessToken: 'mock-access-token', + projectRef: 'mock-project-ref', + }) + + const tools = await client.tools() + + if (!tools || !('search_docs' in tools)) { + throw new Error( + 'Connected to the MCP server but `search_docs` was not returned. ' + + 'The tool contract may have drifted, or the server is misconfigured.' + ) + } + + console.log('✅ Eval MCP preflight OK — connected and `search_docs` is available.') + } finally { + await client?.close().catch(() => {}) + } +} + +runPreflight().catch((error) => { + console.error( + '❌ Eval MCP preflight failed — the eval harness cannot reach the MCP server, ' + + 'so evals would fail. Check NEXT_PUBLIC_MCP_URL, the access token, and the ' + + '@supabase/mcp-server-supabase dependency.' + ) + console.error(error instanceof Error ? error.message : error) + process.exit(1) +}) diff --git a/apps/studio/lib/ai/supabase-mcp.test.ts b/apps/studio/lib/ai/supabase-mcp.test.ts new file mode 100644 index 00000000000..eae88b4186e --- /dev/null +++ b/apps/studio/lib/ai/supabase-mcp.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { createSupabaseMCPClient } from './supabase-mcp' + +const createMCPClientMock = vi.fn() + +vi.mock('@ai-sdk/mcp', () => ({ + createMCPClient: (...args: any[]) => createMCPClientMock(...args), +})) + +const ACCESS_TOKEN = 'test-access-token' +const PROJECT_REF = 'abcdefghijklmnopqrst' + +function getTransportConfig() { + expect(createMCPClientMock).toHaveBeenCalledTimes(1) + return createMCPClientMock.mock.calls[0][0] +} + +describe('createSupabaseMCPClient', () => { + const originalMcpUrl = process.env.NEXT_PUBLIC_MCP_URL + const originalSha = process.env.VERCEL_GIT_COMMIT_SHA + + beforeEach(() => { + vi.clearAllMocks() + createMCPClientMock.mockResolvedValue({ tools: vi.fn() }) + delete process.env.VERCEL_GIT_COMMIT_SHA + }) + + afterEach(() => { + if (originalMcpUrl === undefined) delete process.env.NEXT_PUBLIC_MCP_URL + else process.env.NEXT_PUBLIC_MCP_URL = originalMcpUrl + if (originalSha === undefined) delete process.env.VERCEL_GIT_COMMIT_SHA + else process.env.VERCEL_GIT_COMMIT_SHA = originalSha + }) + + it('connects over the HTTP transport with the supabase-studio client name', async () => { + process.env.NEXT_PUBLIC_MCP_URL = 'https://mcp.supabase.com/mcp' + + await createSupabaseMCPClient({ accessToken: ACCESS_TOKEN, projectRef: PROJECT_REF }) + + const config = getTransportConfig() + expect(config.name).toBe('supabase-studio') + expect(config.transport.type).toBe('http') + }) + + it('targets the URL from NEXT_PUBLIC_MCP_URL with project_ref and read_only', async () => { + process.env.NEXT_PUBLIC_MCP_URL = 'https://mcp.supabase.com/mcp' + + await createSupabaseMCPClient({ accessToken: ACCESS_TOKEN, projectRef: PROJECT_REF }) + + const url = new URL(getTransportConfig().transport.url) + expect(url.origin + url.pathname).toBe('https://mcp.supabase.com/mcp') + expect(url.searchParams.get('project_ref')).toBe(PROJECT_REF) + expect(url.searchParams.get('read_only')).toBe('true') + }) + + it('forwards the dashboard access token as a bearer header', async () => { + process.env.NEXT_PUBLIC_MCP_URL = 'https://mcp.supabase.com/mcp' + + await createSupabaseMCPClient({ accessToken: ACCESS_TOKEN, projectRef: PROJECT_REF }) + + expect(getTransportConfig().transport.headers.Authorization).toBe(`Bearer ${ACCESS_TOKEN}`) + }) + + it('identifies assistant traffic with the x-source-name header', async () => { + process.env.NEXT_PUBLIC_MCP_URL = 'https://mcp.supabase.com/mcp' + + await createSupabaseMCPClient({ accessToken: ACCESS_TOKEN, projectRef: PROJECT_REF }) + + expect(getTransportConfig().transport.headers['x-source-name']).toBe('supabase-studio') + }) + + it('sends x-source-version from VERCEL_GIT_COMMIT_SHA when available', async () => { + process.env.NEXT_PUBLIC_MCP_URL = 'https://mcp.supabase.com/mcp' + process.env.VERCEL_GIT_COMMIT_SHA = 'abc1234' + + await createSupabaseMCPClient({ accessToken: ACCESS_TOKEN, projectRef: PROJECT_REF }) + + expect(getTransportConfig().transport.headers['x-source-version']).toBe('abc1234') + }) + + it('omits x-source-version when VERCEL_GIT_COMMIT_SHA is not set', async () => { + process.env.NEXT_PUBLIC_MCP_URL = 'https://mcp.supabase.com/mcp' + delete process.env.VERCEL_GIT_COMMIT_SHA + + await createSupabaseMCPClient({ accessToken: ACCESS_TOKEN, projectRef: PROJECT_REF }) + + expect(getTransportConfig().transport.headers).not.toHaveProperty('x-source-version') + }) + + it('never leaks the access token into the URL', async () => { + process.env.NEXT_PUBLIC_MCP_URL = 'https://mcp.supabase.com/mcp' + + await createSupabaseMCPClient({ accessToken: ACCESS_TOKEN, projectRef: PROJECT_REF }) + + expect(getTransportConfig().transport.url).not.toContain(ACCESS_TOKEN) + }) + + it('always requests read-only mode', async () => { + process.env.NEXT_PUBLIC_MCP_URL = 'https://mcp.supabase.com/mcp' + + await createSupabaseMCPClient({ accessToken: ACCESS_TOKEN, projectRef: PROJECT_REF }) + + const url = new URL(getTransportConfig().transport.url) + expect(url.searchParams.get('read_only')).toBe('true') + }) + + it('falls back to the default local URL when NEXT_PUBLIC_MCP_URL is unset', async () => { + delete process.env.NEXT_PUBLIC_MCP_URL + + await createSupabaseMCPClient({ accessToken: ACCESS_TOKEN, projectRef: PROJECT_REF }) + + const url = new URL(getTransportConfig().transport.url) + expect(url.origin + url.pathname).toBe('http://localhost:8080/mcp') + expect(url.searchParams.get('project_ref')).toBe(PROJECT_REF) + }) + + it('falls back to the default local URL when NEXT_PUBLIC_MCP_URL is an empty string', async () => { + process.env.NEXT_PUBLIC_MCP_URL = '' + + await createSupabaseMCPClient({ accessToken: ACCESS_TOKEN, projectRef: PROJECT_REF }) + + const url = new URL(getTransportConfig().transport.url) + expect(url.origin + url.pathname).toBe('http://localhost:8080/mcp') + }) + + it('returns the client created by createMCPClient', async () => { + process.env.NEXT_PUBLIC_MCP_URL = 'https://mcp.supabase.com/mcp' + const fakeClient = { tools: vi.fn() } + createMCPClientMock.mockResolvedValueOnce(fakeClient) + + const client = await createSupabaseMCPClient({ + accessToken: ACCESS_TOKEN, + projectRef: PROJECT_REF, + }) + + expect(client).toBe(fakeClient) + }) +}) diff --git a/apps/studio/lib/ai/supabase-mcp.ts b/apps/studio/lib/ai/supabase-mcp.ts index a508ad94a94..bbc65500f7b 100644 --- a/apps/studio/lib/ai/supabase-mcp.ts +++ b/apps/studio/lib/ai/supabase-mcp.ts @@ -1,17 +1,113 @@ import { createMCPClient } from '@ai-sdk/mcp' -import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' // .js required for esbuild ESM resolution -import { createSupabaseMcpServer } from '@supabase/mcp-server-supabase' -import { createSupabaseApiPlatform } from '@supabase/mcp-server-supabase/platform/api' -import { API_URL } from '@/lib/constants' +/** + * Default MCP server URL used when `NEXT_PUBLIC_MCP_URL` is not configured (local + * development). Mirrors `DEFAULT_MCP_URL_PLATFORM` in `ui-patterns/McpUrlBuilder` + * so the assistant resolves the same endpoint as the Connect sheet. It's + * duplicated here (rather than imported) to keep + * `ui-patterns/McpUrlBuilder/constants` — which pulls in `next/image` and image + * assets — out of this server-side bundle. + */ +const DEFAULT_MCP_URL = 'http://localhost:8080/mcp' +/** + * Identifies assistant traffic to the remote MCP server. Sent both as the MCP + * client name (logged as `client_name`) and via the `x-source-name` header + * (logged as `source_name`) by the mgmt-api McpLogger, so assistant requests are + * attributable in the MCP server's logs. + */ +const SOURCE_NAME = 'supabase-studio' + +/** + * Builds the remote MCP endpoint URL for the dashboard assistant. + * + * Points at the remote MCP server configured via `NEXT_PUBLIC_MCP_URL` (e.g. + * https://mcp.supabase.com/mcp), falling back to a local default for development. + * The query parameters (`project_ref`, `read_only`) mirror `getMcpUrl` in + * `ui-patterns/McpUrlBuilder/utils/getMcpUrl` so the assistant and the Connect sheet stay in + * sync. The assistant only performs read operations, so `read_only` is always + * set. + * + * Note: the assistant only talks to the remote MCP server on the hosted platform + * (see `getTools` / `getMcpTools`), so no self-hosted branch is needed here. + */ +function getRemoteMcpUrl(projectRef: string) { + // `||` (not `??`) so an empty-string env var falls back instead of producing + // an invalid `new URL('')`. + const url = new URL(process.env.NEXT_PUBLIC_MCP_URL || DEFAULT_MCP_URL) + if (projectRef) { + url.searchParams.set('project_ref', projectRef) + } + url.searchParams.set('read_only', 'true') + + return url.toString() +} + +/** + * Creates an MCP client connected to the remote Supabase MCP server over HTTP. + * + * Previously the assistant instantiated the MCP server in-process and connected + * to it via an in-memory transport. It now connects to the remote MCP server so + * the dashboard assistant shares the same MCP surface as external clients. + * + * The dashboard session `accessToken` is forwarded as a bearer token. The remote + * MCP server is responsible for validating it and scoping access to the project. + */ export async function createSupabaseMCPClient({ accessToken, - projectId, + projectRef, }: { accessToken: string - projectId: string + projectRef: string }) { + // Identifies the deployed build in the MCP server's `source_version` log field. + const sourceVersion = process.env.VERCEL_GIT_COMMIT_SHA + + const client = await createMCPClient({ + name: SOURCE_NAME, + transport: { + type: 'http', + url: getRemoteMcpUrl(projectRef), + headers: { + Authorization: `Bearer ${accessToken}`, + // Identify assistant traffic in the remote MCP server's logs + 'x-source-name': SOURCE_NAME, + ...(sourceVersion ? { 'x-source-version': sourceVersion } : {}), + }, + }, + }) + + return client +} + +/** + * Legacy in-process MCP client — the pre-migration behavior, kept as a fallback + * behind the `USE_REMOTE_MCP` gate (see `tools/mcp-tools.ts`). + * + * Instantiates `@supabase/mcp-server-supabase` in-process and connects to it over + * an in-memory transport. The heavy server package is imported dynamically so it + * is code-split into its own chunk and stays out of the (default, post-migration) + * remote path's bundle. + * + * TODO(AI-897): remove in process mcp — delete this once every environment has + * been flipped to the remote MCP server and has been stable. Tracked alongside + * the `USE_REMOTE_MCP` rollout. + */ +export async function createInProcessSupabaseMCPClient({ + accessToken, + projectRef, +}: { + accessToken: string + projectRef: string +}) { + // Dynamic imports keep the in-process server + its transport out of the remote + // path's bundle (loaded only when this fallback is actually taken). + // `.js` is required for esbuild ESM resolution. + const { InMemoryTransport } = await import('@modelcontextprotocol/sdk/inMemory.js') + const { createSupabaseMcpServer } = await import('@supabase/mcp-server-supabase') + const { createSupabaseApiPlatform } = await import('@supabase/mcp-server-supabase/platform/api') + const { API_URL } = await import('@/lib/constants') + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() // Instantiate the MCP server and connect to its transport @@ -22,14 +118,13 @@ export async function createSupabaseMCPClient({ apiUrl, }), contentApiUrl: process.env.NEXT_PUBLIC_CONTENT_API_URL, - projectId, + projectId: projectRef, readOnly: true, }) await server.connect(serverTransport) - // Create the MCP client and connect to its transport const client = await createMCPClient({ - name: 'supabase-studio', + name: SOURCE_NAME, transport: clientTransport, }) diff --git a/apps/studio/lib/ai/tools/index.test.ts b/apps/studio/lib/ai/tools/index.test.ts new file mode 100644 index 00000000000..fb927e1bb29 --- /dev/null +++ b/apps/studio/lib/ai/tools/index.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { getTools } from './index' +import { getMcpTools } from './mcp-tools' + +vi.mock('common', () => ({ IS_PLATFORM: true })) + +vi.mock('./mcp-tools', () => ({ getMcpTools: vi.fn() })) +vi.mock('./studio-tools', () => ({ getStudioTools: vi.fn(() => ({ studio_tool: {} })) })) +vi.mock('./schema-tools', () => ({ getSchemaTools: vi.fn(() => ({ schema_tool: {} })) })) +vi.mock('./incident-tools', () => ({ getIncidentTools: vi.fn(() => ({ incident_tool: {} })) })) +vi.mock('./fallback-tools', () => ({ getFallbackTools: vi.fn(() => ({ fallback_tool: {} })) })) +// Identity filter so assertions can check the raw merged tool set +vi.mock('../tool-filter', () => ({ filterToolsByOptInLevel: vi.fn((tools) => tools) })) + +const BASE_PARAMS = { + projectRef: 'abcdefghijklmnopqrst', + connectionString: 'postgresql://localhost', + authorization: 'Bearer token', + aiOptInLevel: 'schema_and_log_and_data' as const, + accessToken: 'access-token', + baseUrl: 'https://supabase.com/dashboard', + signal: new AbortController().signal, +} + +describe('ai/tools getTools', () => { + beforeEach(async () => { + vi.clearAllMocks() + vi.mocked(getMcpTools).mockResolvedValue({ list_tables: {} } as any) + // Reset to platform each test; the self-hosted test overrides to false. + // Done here (not afterEach) so the spy can't leak across tests via order. + const common = await import('common') + vi.spyOn(common, 'IS_PLATFORM', 'get').mockReturnValue(true) + }) + + it('includes studio, MCP, schema and incident tools on platform', async () => { + const tools = await getTools(BASE_PARAMS) + + expect(getMcpTools).toHaveBeenCalledWith({ + accessToken: BASE_PARAMS.accessToken, + projectRef: BASE_PARAMS.projectRef, + aiOptInLevel: BASE_PARAMS.aiOptInLevel, + signal: BASE_PARAMS.signal, + }) + expect(tools).toHaveProperty('studio_tool') + expect(tools).toHaveProperty('list_tables') + expect(tools).toHaveProperty('schema_tool') + expect(tools).toHaveProperty('incident_tool') + }) + + it('degrades gracefully to the remaining tools when remote MCP fetch fails', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(getMcpTools).mockRejectedValueOnce(new Error('remote MCP unreachable')) + + const tools = await getTools(BASE_PARAMS) + + // The assistant still works with the non-MCP tools instead of throwing + expect(tools).toHaveProperty('studio_tool') + expect(tools).toHaveProperty('schema_tool') + expect(tools).toHaveProperty('incident_tool') + expect(tools).not.toHaveProperty('list_tables') + expect(consoleSpy).toHaveBeenCalled() + + consoleSpy.mockRestore() + }) + + it('does not fetch MCP tools when no access token is provided', async () => { + const tools = await getTools({ ...BASE_PARAMS, accessToken: undefined }) + + expect(getMcpTools).not.toHaveBeenCalled() + expect(tools).toHaveProperty('studio_tool') + expect(tools).not.toHaveProperty('list_tables') + }) + + it('uses fallback tools and skips MCP when self-hosted', async () => { + const common = await import('common') + vi.spyOn(common, 'IS_PLATFORM', 'get').mockReturnValue(false) + + const tools = await getTools(BASE_PARAMS) + + expect(getMcpTools).not.toHaveBeenCalled() + expect(tools).toHaveProperty('studio_tool') + expect(tools).toHaveProperty('fallback_tool') + expect(tools).not.toHaveProperty('list_tables') + }) +}) diff --git a/apps/studio/lib/ai/tools/index.ts b/apps/studio/lib/ai/tools/index.ts index 56102becf92..295921d1c70 100644 --- a/apps/studio/lib/ai/tools/index.ts +++ b/apps/studio/lib/ai/tools/index.ts @@ -16,6 +16,7 @@ export const getTools = async ({ aiOptInLevel, accessToken, baseUrl, + signal, }: { projectRef: string connectionString: string @@ -23,6 +24,9 @@ export const getTools = async ({ aiOptInLevel: AiOptInLevel accessToken?: string baseUrl?: string + // Required: tools fetched from the remote MCP server hold an HTTP connection + // that is closed when this signal aborts (i.e. when the request ends). + signal: AbortSignal }) => { // Always include studio tools let tools: ToolSet = getStudioTools({ projectRef, connectionString, authorization, aiOptInLevel }) @@ -39,12 +43,22 @@ export const getTools = async ({ }), } } else if (accessToken) { - // If platform, fetch MCP and other platform specific tools - const mcpTools = await getMcpTools({ - accessToken, - projectRef, - aiOptInLevel, - }) + // If platform, fetch MCP and other platform specific tools. The MCP tools + // may be fetched from the remote MCP server over the network (see + // `USE_REMOTE_MCP`), so a failure there (outage, timeout, auth) should + // degrade gracefully to the remaining tools rather than break the entire + // assistant. + let mcpTools: ToolSet = {} + try { + mcpTools = await getMcpTools({ + accessToken, + projectRef, + aiOptInLevel, + signal, + }) + } catch (error) { + console.error('Failed to fetch MCP tools:', error) + } tools = { ...tools, diff --git a/apps/studio/lib/ai/tools/mcp-tools.test.ts b/apps/studio/lib/ai/tools/mcp-tools.test.ts new file mode 100644 index 00000000000..5290cd99b01 --- /dev/null +++ b/apps/studio/lib/ai/tools/mcp-tools.test.ts @@ -0,0 +1,172 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { createInProcessSupabaseMCPClient, createSupabaseMCPClient } from '../supabase-mcp' +import { getMcpTools } from './mcp-tools' + +vi.mock('../supabase-mcp', () => ({ + createSupabaseMCPClient: vi.fn(), + createInProcessSupabaseMCPClient: vi.fn(), +})) + +const BASE_PARAMS = { + accessToken: 'token', + projectRef: 'abcdefghijklmnopqrst', + aiOptInLevel: 'schema_and_log_and_data' as const, + // A fresh, non-aborted signal by default; lifecycle tests override it + signal: new AbortController().signal, +} + +// A realistic remote tool set: all expected read tools plus the UI-executed ones +const FULL_REMOTE_TOOLS = { + search_docs: { description: 'docs' }, + list_tables: { description: 'list tables' }, + list_extensions: { description: 'extensions' }, + list_edge_functions: { description: 'edge functions' }, + list_branches: { description: 'branches' }, + get_advisors: { description: 'advisors' }, + get_logs: { description: 'get logs' }, + execute_sql: { description: 'execute sql' }, + deploy_edge_function: { description: 'deploy' }, +} + +describe('ai/tools/mcp-tools getMcpTools', () => { + let close: ReturnType + let tools: ReturnType + let consoleErrorSpy: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + // These tests exercise the remote transport; pin the migration gate to it. + process.env.USE_REMOTE_MCP = 'true' + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + close = vi.fn().mockResolvedValue(undefined) + tools = vi.fn().mockResolvedValue({ ...FULL_REMOTE_TOOLS }) + vi.mocked(createSupabaseMCPClient).mockResolvedValue({ tools, close } as any) + }) + + afterEach(() => { + delete process.env.USE_REMOTE_MCP + consoleErrorSpy.mockRestore() + }) + + it('returns MCP tools and strips UI-executed tools handled locally', async () => { + const result = await getMcpTools(BASE_PARAMS) + + expect(result).toHaveProperty('list_tables') + expect(result).toHaveProperty('get_logs') + expect(result).not.toHaveProperty('execute_sql') + expect(result).not.toHaveProperty('deploy_edge_function') + }) + + it('warns when the remote server is missing an expected tool (contract drift)', async () => { + const { list_branches, ...withoutBranches } = FULL_REMOTE_TOOLS + tools.mockResolvedValueOnce(withoutBranches) + + await getMcpTools(BASE_PARAMS) + + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('list_branches')) + }) + + it('does not warn about drift when all expected tools are present', async () => { + await getMcpTools(BASE_PARAMS) + + const driftWarnings = consoleErrorSpy.mock.calls.filter( + ([msg]: unknown[]) => typeof msg === 'string' && msg.includes('missing expected tools') + ) + expect(driftWarnings).toHaveLength(0) + }) + + it('keeps the connection open until the request signal aborts', async () => { + const controller = new AbortController() + + await getMcpTools({ ...BASE_PARAMS, signal: controller.signal }) + // Tools execute later during streaming, so the client must stay open + expect(close).not.toHaveBeenCalled() + + controller.abort() + await Promise.resolve() + expect(close).toHaveBeenCalledTimes(1) + }) + + it('closes the client and skips fetching tools when the signal is already aborted', async () => { + const controller = new AbortController() + controller.abort() + + const result = await getMcpTools({ ...BASE_PARAMS, signal: controller.signal }) + + expect(close).toHaveBeenCalledTimes(1) + expect(tools).not.toHaveBeenCalled() + expect(result).toEqual({}) + }) + + it('closes the client and rethrows when fetching tools fails, without double-closing on later abort', async () => { + tools.mockRejectedValueOnce(new Error('network unreachable')) + const controller = new AbortController() + + await expect(getMcpTools({ ...BASE_PARAMS, signal: controller.signal })).rejects.toThrow( + 'network unreachable' + ) + expect(close).toHaveBeenCalledTimes(1) + + // A subsequent abort must not close the (already closed) client again + controller.abort() + await Promise.resolve() + expect(close).toHaveBeenCalledTimes(1) + }) + + it('closes the client and rethrows when tool validation fails', async () => { + // A known tool name with a non-object value passes the opt-in filter but + // fails schema validation + tools.mockResolvedValueOnce({ ...FULL_REMOTE_TOOLS, list_tables: 'not-an-object' }) + + await expect(getMcpTools(BASE_PARAMS)).rejects.toThrow('MCP tools validation failed') + expect(close).toHaveBeenCalledTimes(1) + }) +}) + +describe('ai/tools/mcp-tools getMcpTools transport selection', () => { + let close: ReturnType + let tools: ReturnType + let consoleErrorSpy: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + close = vi.fn().mockResolvedValue(undefined) + tools = vi.fn().mockResolvedValue({ ...FULL_REMOTE_TOOLS }) + vi.mocked(createSupabaseMCPClient).mockResolvedValue({ tools, close } as any) + vi.mocked(createInProcessSupabaseMCPClient).mockResolvedValue({ tools, close } as any) + }) + + afterEach(() => { + delete process.env.USE_REMOTE_MCP + consoleErrorSpy.mockRestore() + }) + + it('uses the remote client when USE_REMOTE_MCP is "true"', async () => { + process.env.USE_REMOTE_MCP = 'true' + + await getMcpTools(BASE_PARAMS) + + expect(createSupabaseMCPClient).toHaveBeenCalledTimes(1) + expect(createInProcessSupabaseMCPClient).not.toHaveBeenCalled() + }) + + it('falls back to the in-process client when USE_REMOTE_MCP is unset (default)', async () => { + delete process.env.USE_REMOTE_MCP + + await getMcpTools(BASE_PARAMS) + + expect(createInProcessSupabaseMCPClient).toHaveBeenCalledTimes(1) + expect(createSupabaseMCPClient).not.toHaveBeenCalled() + }) + + it('uses the in-process client for any non-"true" value', async () => { + process.env.USE_REMOTE_MCP = 'false' + + await getMcpTools(BASE_PARAMS) + + expect(createInProcessSupabaseMCPClient).toHaveBeenCalledTimes(1) + expect(createSupabaseMCPClient).not.toHaveBeenCalled() + }) +}) diff --git a/apps/studio/lib/ai/tools/mcp-tools.ts b/apps/studio/lib/ai/tools/mcp-tools.ts index 3ccfcc9c992..f895f6d90b9 100644 --- a/apps/studio/lib/ai/tools/mcp-tools.ts +++ b/apps/studio/lib/ai/tools/mcp-tools.ts @@ -1,42 +1,135 @@ +// Type-only import (erased at build time — pulls no runtime code into this route). +import type * as SupabaseMcp from '@supabase/mcp-server-supabase' import type { ToolSet } from 'ai' -import { createSupabaseMCPClient } from '../supabase-mcp' +import { createInProcessSupabaseMCPClient, createSupabaseMCPClient } from '../supabase-mcp' import { filterToolsByOptInLevel, toolSetValidationSchema } from '../tool-filter' import type { AiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi' -const UI_EXECUTED_TOOLS = ['execute_sql', 'deploy_edge_function'] +/** + * Union of the tool names exposed by the pinned `@supabase/mcp-server-supabase` + * version. Studio's dependency is bumped in lockstep with the remote MCP server + * (via automated bump PRs), so typing our tool-name lists against this makes an + * upstream rename/removal a **compile-time** failure (`pnpm typecheck`) in that + * PR, instead of a silent capability loss at runtime. + */ +type SupabaseMcpToolName = keyof typeof SupabaseMcp.supabaseMcpToolSchemas + +// UI-executed tools handled locally by Studio (see getStudioTools); the remote +// MCP server's versions are removed so the UI-controlled Studio versions win. +const UI_EXECUTED_TOOLS = [ + 'execute_sql', + 'deploy_edge_function', +] as const satisfies readonly SupabaseMcpToolName[] + +// Read-only tools the assistant relies on from the remote MCP server — the +// MCP-sourced subset of the allowlist in tool-filter.ts (TOOL_CATEGORY_MAP). +// `satisfies` gives the compile-time drift guard; the runtime check below also +// catches a deployed server that returns fewer tools (feature flags / version +// skew). The allowlist remains the source of truth for what is allowed. +const EXPECTED_MCP_TOOLS = [ + 'search_docs', + 'list_tables', + 'list_extensions', + 'list_edge_functions', + 'list_branches', + 'get_advisors', + 'get_logs', +] as const satisfies readonly SupabaseMcpToolName[] export const getMcpTools = async ({ accessToken, projectRef, aiOptInLevel, + signal, }: { accessToken: string projectRef: string aiOptInLevel: AiOptInLevel + // Required: the remote client holds an HTTP connection that must be torn down + // when the request ends. The caller owns that lifecycle via this signal. + signal: AbortSignal }) => { - // If platform, fetch MCP client and tools which replace old local tools - const mcpClient = await createSupabaseMCPClient({ + // Connect to the MCP server and fetch its tools, which replace the old local + // tools. `USE_REMOTE_MCP` gates the transport: the remote HTTP server (target + // state) or the legacy in-process server (fallback during the migration), + // defaulting to in-process until an environment opts in. Flip it per + // environment (staging → prod → Nimbus) once each one's prerequisites are met + // (dashboard-token support on the MCP API, remote MCP enabled for Nimbus); + // unset to roll back on the next deploy. Both transports expose the same tool + // surface, so the filtering, drift detection, and lifecycle handling below are + // transport-agnostic. + // + // TODO(AI-897): remove in process mcp — once every environment has been + // flipped and is stable, delete `createInProcessSupabaseMCPClient` and this + // fallback branch. + const useRemoteMcp = process.env.USE_REMOTE_MCP === 'true' + const createClient = useRemoteMcp ? createSupabaseMCPClient : createInProcessSupabaseMCPClient + const mcpClient = await createClient({ accessToken, - projectId: projectRef, + projectRef, }) - const availableMcpTools = (await mcpClient.tools()) as ToolSet - // Filter tools based on the (potentially modified) AI opt-in level - const allowedMcpTools = filterToolsByOptInLevel(availableMcpTools, aiOptInLevel) - - // Remove UI-executed tools handled locally - const filteredMcpTools: ToolSet = { ...allowedMcpTools } - UI_EXECUTED_TOOLS.forEach((toolName) => { - delete filteredMcpTools[toolName] - }) - - // Validate that only known tools are provided - const validation = toolSetValidationSchema.safeParse(filteredMcpTools) - if (!validation.success) { - console.error('MCP tools validation error:', validation.error) - throw new Error('Internal error: MCP tools validation failed') + // The remote client keeps an HTTP connection open. The tools' `execute` + // functions are invoked later, while the response is streaming, so the + // connection must stay open until the request ends. Close it exactly once when + // the request is done (normal completion or abort) to avoid leaking a + // connection per request. + let closed = false + const closeClient = () => { + if (closed) return + closed = true + void mcpClient.close().catch(() => {}) } - return validation.data + // The request already ended before we could fetch tools; don't bother. + if (signal.aborted) { + closeClient() + return {} as ToolSet + } + signal.addEventListener('abort', closeClient, { once: true }) + + try { + const availableMcpTools = (await mcpClient.tools()) as ToolSet + + // Runtime drift detection: `EXPECTED_MCP_TOOLS` is compile-time-checked + // against the pinned package (see its declaration), but the *deployed* remote + // server can still return fewer tools than the pinned types — feature flags, + // killswitches, or version skew during a bump. `filterToolsByOptInLevel` + // drops missing tools silently, so warn to make that observable. + const missingExpectedTools = EXPECTED_MCP_TOOLS.filter((name) => !(name in availableMcpTools)) + if (missingExpectedTools.length > 0) { + console.error( + `Remote MCP server is missing expected tools: ${missingExpectedTools.join(', ')}. ` + + 'The tool contract may have drifted; the assistant will operate without them.' + ) + } + + // Safety gate: `filterToolsByOptInLevel` keeps only tools in the allowlist + // (tool-filter.ts TOOL_CATEGORY_MAP) and drops everything else. This — not + // the `read_only` query param — is what prevents the remote server's + // write/destructive tools (apply_migration, create_branch, ...) from reaching + // the assistant. `read_only` is defense-in-depth (those tools throw at + // runtime). Do not remove this filter on the assumption `read_only` suffices. + const allowedMcpTools = filterToolsByOptInLevel(availableMcpTools, aiOptInLevel) + + // Remove UI-executed tools handled locally + const filteredMcpTools: ToolSet = { ...allowedMcpTools } + UI_EXECUTED_TOOLS.forEach((toolName) => { + delete filteredMcpTools[toolName] + }) + + // Validate that only known tools are provided + const validation = toolSetValidationSchema.safeParse(filteredMcpTools) + if (!validation.success) { + console.error('MCP tools validation error:', validation.error) + throw new Error('Internal error: MCP tools validation failed') + } + + return validation.data + } catch (error) { + // Don't leak the connection if fetching or validating tools fails + closeClient() + throw error + } } diff --git a/apps/studio/lib/ai/tools/mock-tools.test.ts b/apps/studio/lib/ai/tools/mock-tools.test.ts new file mode 100644 index 00000000000..d696d2d762d --- /dev/null +++ b/apps/studio/lib/ai/tools/mock-tools.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { getMockTools } from './mock-tools' +import { createInProcessSupabaseMCPClient } from '@/lib/ai/supabase-mcp' + +// The one real tool in the eval harness (search_docs) is sourced from an +// in-process MCP client. Mock that client so this test stays hermetic and +// guards the wiring, not a live connection. +vi.mock('@/lib/ai/supabase-mcp', () => ({ + createInProcessSupabaseMCPClient: vi.fn(), +})) + +const SEARCH_DOCS = { description: 'search the docs' } + +describe('ai/tools/mock-tools getMockTools', () => { + let close: ReturnType + let tools: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + close = vi.fn().mockResolvedValue(undefined) + tools = vi.fn().mockResolvedValue({ search_docs: SEARCH_DOCS }) + vi.mocked(createInProcessSupabaseMCPClient).mockResolvedValue({ tools, close } as any) + }) + + it('sources the real search_docs from the in-process MCP server alongside the deterministic mocks', async () => { + const result = await getMockTools(undefined, new AbortController().signal) + + expect(createInProcessSupabaseMCPClient).toHaveBeenCalledTimes(1) + // The real tool, wired through from the MCP client + expect(result).toHaveProperty('search_docs', SEARCH_DOCS) + // A couple of the deterministic mocks, to confirm the merge + expect(result).toHaveProperty('list_tables') + expect(result).toHaveProperty('get_logs') + }) + + // This is the regression guard: if the eval's MCP wiring breaks (contract + // drift, or a refactor that stops sourcing search_docs — e.g. the future + // AI-897 removal of the in-process client), fail loudly in normal CI instead + // of only surfacing during an opt-in Braintrust eval run. + it('throws a clear error when the MCP server does not expose search_docs', async () => { + tools.mockResolvedValueOnce({}) + + await expect(getMockTools(undefined, new AbortController().signal)).rejects.toThrow( + 'search_docs tool not available from MCP server' + ) + }) + + it('closes the MCP client when the caller aborts the signal', async () => { + const controller = new AbortController() + + await getMockTools(undefined, controller.signal) + // Connection stays open until generation ends (search_docs runs during it) + expect(close).not.toHaveBeenCalled() + + controller.abort() + await Promise.resolve() + expect(close).toHaveBeenCalledTimes(1) + }) + + afterEach(() => { + vi.clearAllMocks() + }) +}) diff --git a/apps/studio/lib/ai/tools/mock-tools.ts b/apps/studio/lib/ai/tools/mock-tools.ts index 8465459d3d3..f12d9b0e55e 100644 --- a/apps/studio/lib/ai/tools/mock-tools.ts +++ b/apps/studio/lib/ai/tools/mock-tools.ts @@ -1,9 +1,9 @@ import assert from 'node:assert' -import { tool } from 'ai' +import { tool, type ToolSet } from 'ai' import { z } from 'zod' import { getStudioTools } from '../tools/studio-tools' -import { getMcpTools } from '@/lib/ai/tools/mcp-tools' +import { createInProcessSupabaseMCPClient } from '@/lib/ai/supabase-mcp' const listTablesInputSchema = z.object({ schemas: z.array(z.string()).describe('The schema names to list.'), @@ -306,14 +306,25 @@ export type MockToolOverrides = { * * Note: search_docs uses the real implementation */ -export async function getMockTools(overrides?: MockToolOverrides) { +export async function getMockTools(overrides: MockToolOverrides | undefined, signal: AbortSignal) { const mockedStudioTools = createMockedStudioTools() - const { search_docs } = await getMcpTools({ + // Every tool here is a deterministic mock except `search_docs`, which uses the + // real implementation. We source it from an in-process MCP server directly + // (rather than `getMcpTools`) so the eval harness stays hermetic and decoupled + // from the assistant's transport gate (`USE_REMOTE_MCP`): the in-process server + // needs no live remote endpoint or real access token. See AI-897 for how to + // point evals at the remote MCP server instead. + const mcpClient = await createInProcessSupabaseMCPClient({ accessToken: 'mock-access-token', projectRef: 'mock-project-ref', - aiOptInLevel: 'schema_and_log_and_data', }) + // The caller owns this signal and aborts it once generation is done, which + // closes the client opened here (search_docs executes during generation, so + // the connection must stay open until then). + signal.addEventListener('abort', () => void mcpClient.close().catch(() => {}), { once: true }) + + const { search_docs } = (await mcpClient.tools()) as ToolSet assert(search_docs, 'search_docs tool not available from MCP server') diff --git a/apps/studio/lib/api/generate-v4.test.ts b/apps/studio/lib/api/generate-v4.test.ts index 7a064364246..5110d04fe8a 100644 --- a/apps/studio/lib/api/generate-v4.test.ts +++ b/apps/studio/lib/api/generate-v4.test.ts @@ -42,6 +42,7 @@ test('generateV4 calls the tool sanitizer', async () => { status: vi.fn(() => mockRes), json: vi.fn(() => mockRes), setHeader: vi.fn(() => mockRes), + on: vi.fn(), } vi.mock('@/lib/ai/ai-details', () => ({ @@ -83,4 +84,7 @@ test('generateV4 calls the tool sanitizer', async () => { await generateV4(mockReq as any, mockRes as any) expect(sanitizeMessagePart).toHaveBeenCalled() + // The response 'close' event must be wired up so the remote MCP connection + // opened in getTools is torn down when the stream finishes or the client drops + expect(mockRes.on).toHaveBeenCalledWith('close', expect.any(Function)) }) diff --git a/apps/studio/package.json b/apps/studio/package.json index 7da743e96fe..6eaf32965b7 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -33,6 +33,7 @@ "build:graphql-types": "tsx scripts/download-graphql-schema.mts && pnpm graphql-codegen --config scripts/codegen.ts", "build:graphql-types:watch": "pnpm graphql-codegen --config scripts/codegen.ts --watch", "evals:setup": "cp node_modules/libpg-query/wasm/libpg-query.wasm evals/libpg-query.wasm", + "evals:preflight": "tsx evals/preflight.ts", "evals:run": "braintrust eval --no-send-logs evals/assistant.eval.ts", "evals:upload": "braintrust eval evals/assistant.eval.ts", "scorers:deploy": "IS_BRAINTRUST_PUSH=true braintrust push evals/scorer-online.ts" diff --git a/apps/studio/pages/api/ai/sql/generate-v4.ts b/apps/studio/pages/api/ai/sql/generate-v4.ts index 457881205c7..1bbc78aa29b 100644 --- a/apps/studio/pages/api/ai/sql/generate-v4.ts +++ b/apps/studio/pages/api/ai/sql/generate-v4.ts @@ -165,6 +165,9 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw const abortController = new AbortController() req.on('close', () => abortController.abort()) req.on('aborted', () => abortController.abort()) + // Fires when the response finishes streaming or the connection drops, which + // is what tears down the remote MCP connection opened in getTools. + res.on('close', () => abortController.abort()) const tools = await getTools({ projectRef, @@ -173,6 +176,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw aiOptInLevel, accessToken, baseUrl: getURL(), + signal: abortController.signal, }) // Get a list of all schemas to add to context diff --git a/apps/studio/pages/api/ai/sql/policy.ts b/apps/studio/pages/api/ai/sql/policy.ts index a6c30ebd76f..8cb27ed7061 100644 --- a/apps/studio/pages/api/ai/sql/policy.ts +++ b/apps/studio/pages/api/ai/sql/policy.ts @@ -100,53 +100,67 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) { return res.status(500).json({ error: modelError.message }) } - const tools = await getTools({ - projectRef, - connectionString, - authorization, - aiOptInLevel, - accessToken, - }) + // Closes the remote MCP connection opened in getTools when generation is done, + // if anything below throws, or if the client disconnects mid-generation so the + // connection isn't held until generateText resolves on its own (mirrors the + // request-scoped cleanup in generate-v4.ts). + const toolsAbortController = new AbortController() + req.on('close', () => toolsAbortController.abort()) + req.on('aborted', () => toolsAbortController.abort()) + // Fires when the response finishes or the connection drops. + res.on('close', () => toolsAbortController.abort()) + try { + const tools = await getTools({ + projectRef, + connectionString, + authorization, + aiOptInLevel, + accessToken, + signal: toolsAbortController.signal, + }) - const { experimental_output } = await generateText({ - ...modelParams, - stopWhen: stepCountIs(5), - prompt: source` - You are a Postgres RLS (Row Level Security) expert. - Determine the most appropriate policies for the "${schema}"."${tableName}" table within a Supabase project. + const { experimental_output } = await generateText({ + ...modelParams, + stopWhen: stepCountIs(5), + prompt: source` + You are a Postgres RLS (Row Level Security) expert. + Determine the most appropriate policies for the "${schema}"."${tableName}" table within a Supabase project. - ${columns.length > 0 ? `Table columns: ${columns.join(', ')}` : 'No column metadata provided.'} + ${columns.length > 0 ? `Table columns: ${columns.join(', ')}` : 'No column metadata provided.'} - ${message ? `User request: ${message}` : ''} + ${message ? `User request: ${message}` : ''} - RLS Guide: ${RLS_PROMPT} + RLS Guide: ${RLS_PROMPT} - Requirements: - - Use the available planning and schema tools (like "list_policies" or "list_tables") to inspect the "${schema}" schema and existing policies before generating new ones. - - Ensure policies strictly adhere to the existing schema - - Return a curated list of recommended CREATE POLICY statements as JSON. - - Each policy must include: name, sql, command (SELECT/INSERT/UPDATE/DELETE/ALL), action (PERMISSIVE/RESTRICTIVE), roles (array of role names). - - Include "definition" (USING clause expression without the USING keyword) for SELECT, UPDATE, DELETE policies. - - Include "check" (WITH CHECK clause expression without the WITH CHECK keywords) for INSERT, UPDATE policies. - - Avoid duplicating existing policies and reference the public schema and typical Supabase best practices when deciding the coverage. - - Prefer PERMISSIVE policies unless a RESTRICTIVE policy is explicitly required - `, - tools, - experimental_output: Output.object({ - schema: z.object({ - policies: z.array(policySchema), + Requirements: + - Use the available planning and schema tools (like "list_policies" or "list_tables") to inspect the "${schema}" schema and existing policies before generating new ones. + - Ensure policies strictly adhere to the existing schema + - Return a curated list of recommended CREATE POLICY statements as JSON. + - Each policy must include: name, sql, command (SELECT/INSERT/UPDATE/DELETE/ALL), action (PERMISSIVE/RESTRICTIVE), roles (array of role names). + - Include "definition" (USING clause expression without the USING keyword) for SELECT, UPDATE, DELETE policies. + - Include "check" (WITH CHECK clause expression without the WITH CHECK keywords) for INSERT, UPDATE policies. + - Avoid duplicating existing policies and reference the public schema and typical Supabase best practices when deciding the coverage. + - Prefer PERMISSIVE policies unless a RESTRICTIVE policy is explicitly required + `, + tools, + experimental_output: Output.object({ + schema: z.object({ + policies: z.array(policySchema), + }), }), - }), - }) + }) - // Add table and schema to each policy from the request - const policies = (experimental_output?.policies ?? []).map((policy) => ({ - ...policy, - table: tableName, - schema, - })) + // Add table and schema to each policy from the request + const policies = (experimental_output?.policies ?? []).map((policy) => ({ + ...policy, + table: tableName, + schema, + })) - return res.json(policies) + return res.json(policies) + } finally { + toolsAbortController.abort() + } } catch (error) { if (error instanceof Error) { console.error(`AI policy generation failed: ${error.message}`) diff --git a/apps/studio/turbo.jsonc b/apps/studio/turbo.jsonc index d47f1beea95..08a66e00023 100644 --- a/apps/studio/turbo.jsonc +++ b/apps/studio/turbo.jsonc @@ -71,6 +71,9 @@ "OPENAI_API_KEY", "BRAINTRUST_API_KEY", "BRAINTRUST_PROJECT_ID", + // Gates the dashboard assistant between the remote MCP server and the + // legacy in-process one (see lib/ai/tools/mcp-tools.ts). + "USE_REMOTE_MCP", "AUTH_JWT_SECRET", "LOGFLARE_API_KEY", "LOGFLARE_PUBLIC_ACCESS_TOKEN", From 8f5ba52e2c06ae9f67c3fd7fc6b344f6e6a6f5a6 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Tue, 7 Jul 2026 13:21:57 -0600 Subject: [PATCH 04/54] fix(e2e): avoid response-listener race in SQL editor flaky test (#47698) ## Summary - Fixes the flaky "should check if SQL editor is working as expected" self-hosted E2E test (FE-3863) - The second query registered the pg-meta response waiter *after* clicking run, so a fast response could be missed, causing a 30s timeout. Moved the waiter registration before the click, matching the pattern already used for the first query. Fixes FE-3863 ## Summary by CodeRabbit * **Tests** * Improved SQL editor end-to-end test synchronization to better wait for the correct API response during repeated query runs. * Increased test reliability for scenarios involving back-to-back SQL executions. --- e2e/studio/features/sql-editor.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/e2e/studio/features/sql-editor.spec.ts b/e2e/studio/features/sql-editor.spec.ts index 44f2a6500ca..1c7b02f5e0a 100644 --- a/e2e/studio/features/sql-editor.spec.ts +++ b/e2e/studio/features/sql-editor.spec.ts @@ -145,10 +145,14 @@ test.describe('SQL Editor', () => { await page.locator('.view-lines').click() await page.keyboard.press('ControlOrMeta+KeyA') await page.keyboard.type(`select length('hello');`) + + const secondSqlMutationPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', { + method: 'POST', + }) await page.getByTestId('sql-run-button').click() + await secondSqlMutationPromise // verify the result is updated. - await waitForApiResponse(page, 'pg-meta', ref, 'query?key=', { method: 'POST' }) await expect(page.getByRole('gridcell', { name: '5' })).toBeVisible() await expect(page.getByText('Loading...')).not.toBeVisible() From 561e43b36c02cb5576e7ea3d3a11f9349ace0bcc Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Wed, 8 Jul 2026 09:15:54 +1000 Subject: [PATCH 05/54] Increase lightness of light theme surface (#47713) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adjusts surface value for light theme ## Summary by CodeRabbit * **Style** * Refined the light theme’s color tuning (including a small adjustment to chroma) and retuned the canvas surface tone. * Updated code block light-theme styling so string literal token colors are now `#c46a0a` (applied consistently across the design system, docs, learning, and UI library). --- apps/design-system/styles/code-block-variables.css | 4 ++-- apps/docs/styles/globals.css | 4 ++-- apps/learn/styles/code-block-variables.css | 4 ++-- apps/ui-library/styles/code-block-variables.css | 4 ++-- packages/ui/build/css/themes/light.css | 7 +++++-- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/apps/design-system/styles/code-block-variables.css b/apps/design-system/styles/code-block-variables.css index f12e15fb992..a21af6b3751 100644 --- a/apps/design-system/styles/code-block-variables.css +++ b/apps/design-system/styles/code-block-variables.css @@ -20,11 +20,11 @@ --code-token-keyword: #6b35dc; --code-foreground: oklch(from var(--foreground-light) l c h / 1); --code-token-constant: #15593b; - --code-token-string: #f1a10d; + --code-token-string: #c46a0a; --code-token-comment: #7e7e7e; --code-token-parameter: oklch(from var(--foreground-light) l c h / 1); --code-token-function: #15593b; - --code-token-string-expression: #f1a10d; + --code-token-string-expression: #c46a0a; --code-token-punctuation: oklch(from var(--foreground-light) l c h / 1); --code-token-link: oklch(from var(--foreground-light) l c h / 1); --code-token-number: oklch(from var(--foreground-light) l c h / 1); diff --git a/apps/docs/styles/globals.css b/apps/docs/styles/globals.css index 3fdd46001d9..cb07a3e570d 100644 --- a/apps/docs/styles/globals.css +++ b/apps/docs/styles/globals.css @@ -439,11 +439,11 @@ th code { --code-token-keyword: #6b35dc; --code-foreground: oklch(from var(--foreground-light) l c h / 1); --code-token-constant: #15593b; - --code-token-string: #f1a10d; + --code-token-string: #c46a0a; --code-token-comment: #7e7e7e; --code-token-parameter: oklch(from var(--foreground-light) l c h / 1); --code-token-function: #15593b; - --code-token-string-expression: #f1a10d; + --code-token-string-expression: #c46a0a; --code-token-punctuation: oklch(from var(--foreground-light) l c h / 1); --code-token-link: oklch(from var(--foreground-light) l c h / 1); --code-token-number: oklch(from var(--foreground-light) l c h / 1); diff --git a/apps/learn/styles/code-block-variables.css b/apps/learn/styles/code-block-variables.css index f12e15fb992..a21af6b3751 100644 --- a/apps/learn/styles/code-block-variables.css +++ b/apps/learn/styles/code-block-variables.css @@ -20,11 +20,11 @@ --code-token-keyword: #6b35dc; --code-foreground: oklch(from var(--foreground-light) l c h / 1); --code-token-constant: #15593b; - --code-token-string: #f1a10d; + --code-token-string: #c46a0a; --code-token-comment: #7e7e7e; --code-token-parameter: oklch(from var(--foreground-light) l c h / 1); --code-token-function: #15593b; - --code-token-string-expression: #f1a10d; + --code-token-string-expression: #c46a0a; --code-token-punctuation: oklch(from var(--foreground-light) l c h / 1); --code-token-link: oklch(from var(--foreground-light) l c h / 1); --code-token-number: oklch(from var(--foreground-light) l c h / 1); diff --git a/apps/ui-library/styles/code-block-variables.css b/apps/ui-library/styles/code-block-variables.css index f12e15fb992..a21af6b3751 100644 --- a/apps/ui-library/styles/code-block-variables.css +++ b/apps/ui-library/styles/code-block-variables.css @@ -20,11 +20,11 @@ --code-token-keyword: #6b35dc; --code-foreground: oklch(from var(--foreground-light) l c h / 1); --code-token-constant: #15593b; - --code-token-string: #f1a10d; + --code-token-string: #c46a0a; --code-token-comment: #7e7e7e; --code-token-parameter: oklch(from var(--foreground-light) l c h / 1); --code-token-function: #15593b; - --code-token-string-expression: #f1a10d; + --code-token-string-expression: #c46a0a; --code-token-punctuation: oklch(from var(--foreground-light) l c h / 1); --code-token-link: oklch(from var(--foreground-light) l c h / 1); --code-token-number: oklch(from var(--foreground-light) l c h / 1); diff --git a/packages/ui/build/css/themes/light.css b/packages/ui/build/css/themes/light.css index d23cc96ed63..d54ede5d328 100644 --- a/packages/ui/build/css/themes/light.css +++ b/packages/ui/build/css/themes/light.css @@ -4,14 +4,14 @@ --hue: 159; /* Warm-gray surface ramp against the green brand (--primary stays on --hue). */ --surface-hue: 34; - --chroma: 0.004; + --chroma: 0.001; /* * Soft-gray canvas (not pure white) so elevated surfaces have headroom to * brighten toward white. --elevation-step is positive here: higher surfaces * get lighter, matching dark mode's direction. Tuned literal (previously * derived from 1 - surface); retune alongside --surface. */ - --surface: 0.98; + --surface: 0.995; --elevation-step: 0.024; --contrast: 0.53; --foreground-lightness: 0.16; @@ -32,6 +32,9 @@ --code-block-2: 33.1deg 80% 52.9%; --code-block-1: 170.6deg 43.2% 51%; + --code-token-string: #c46a0a; + --code-token-string-expression: #c46a0a; + /* Stepped numeric scales (per-theme literals; mapped in theme.css) */ --secondary-default: 247.8deg 100% 70%; --secondary-400: 248.3deg 54.5% 25.9%; From 19ee79b0306fdef8b879a00c342d1553dec05f9e Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Wed, 8 Jul 2026 12:30:42 +1000 Subject: [PATCH 06/54] color text refine (#47718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adjusts light theme for better contrast on foreground, muted-foreground and tertiary-foreground text ## Summary by CodeRabbit * **Style** * Retuned the light theme’s surface chroma and updated light, muted, and tertiary foreground levels for improved readability. * Updated the brand link color saturation to better align with the revised theme. * Refreshed code block token colors for both light and dark themes. * **Documentation** * Updated the “Edit this page on GitHub” link styling to use updated token-based text colors for default and hover states. --- apps/docs/features/docs/GuidesMdx.template.tsx | 2 +- apps/docs/features/ui/guide/GuideFooter.tsx | 3 +-- apps/docs/styles/globals.css | 10 +++++----- packages/ui/build/css/themes/light.css | 11 +++++------ 4 files changed, 12 insertions(+), 14 deletions(-) diff --git a/apps/docs/features/docs/GuidesMdx.template.tsx b/apps/docs/features/docs/GuidesMdx.template.tsx index 7ba6e74b818..6ccbf687218 100644 --- a/apps/docs/features/docs/GuidesMdx.template.tsx +++ b/apps/docs/features/docs/GuidesMdx.template.tsx @@ -121,7 +121,7 @@ const GuideTemplate = ({ className={cn( 'w-fit', 'flex items-center gap-1', - 'text-sm text-scale-1000 hover:text-scale-1200', + 'text-sm text-tertiary-foreground hover:text-foreground', 'transition-colors' )} target="_blank" diff --git a/apps/docs/features/ui/guide/GuideFooter.tsx b/apps/docs/features/ui/guide/GuideFooter.tsx index 25ee78e6678..64f1c5c2f01 100644 --- a/apps/docs/features/ui/guide/GuideFooter.tsx +++ b/apps/docs/features/ui/guide/GuideFooter.tsx @@ -17,8 +17,7 @@ export function GuideFooter({ className, editLink }: GuideFooterProps) { className={cn( 'w-fit', 'flex items-center gap-1', - 'text-sm text-scale-1000 hover:text-scale-1200', - 'transition-colors' + 'text-sm text-tertiary-foreground hover:text-foreground' )} target="_blank" rel="noreferrer noopener edit" diff --git a/apps/docs/styles/globals.css b/apps/docs/styles/globals.css index cb07a3e570d..4064aaa2199 100644 --- a/apps/docs/styles/globals.css +++ b/apps/docs/styles/globals.css @@ -424,7 +424,7 @@ th code { --code-foreground: #ffffff; --code-token-constant: #3ecf8e; --code-token-string: #ffcda1; - --code-token-comment: #7e7e7e; + --code-token-comment: #949494; --code-token-parameter: #ffffff; --code-token-function: #3ecf8e; --code-token-string-expression: #ffcda1; @@ -436,14 +436,14 @@ th code { } [data-theme='light'], .light { - --code-token-keyword: #6b35dc; + --code-token-keyword: #5f2fc4; --code-foreground: oklch(from var(--foreground-light) l c h / 1); --code-token-constant: #15593b; - --code-token-string: #c46a0a; - --code-token-comment: #7e7e7e; + --code-token-string: #9a5200; + --code-token-comment: #6a6a6a; --code-token-parameter: oklch(from var(--foreground-light) l c h / 1); --code-token-function: #15593b; - --code-token-string-expression: #c46a0a; + --code-token-string-expression: #9a5200; --code-token-punctuation: oklch(from var(--foreground-light) l c h / 1); --code-token-link: oklch(from var(--foreground-light) l c h / 1); --code-token-number: oklch(from var(--foreground-light) l c h / 1); diff --git a/packages/ui/build/css/themes/light.css b/packages/ui/build/css/themes/light.css index d54ede5d328..8cbccc0ffbf 100644 --- a/packages/ui/build/css/themes/light.css +++ b/packages/ui/build/css/themes/light.css @@ -2,9 +2,8 @@ .light { --helpers-os-appearance: Light; --hue: 159; - /* Warm-gray surface ramp against the green brand (--primary stays on --hue). */ --surface-hue: 34; - --chroma: 0.001; + --chroma: 0; /* * Soft-gray canvas (not pure white) so elevated surfaces have headroom to * brighten toward white. --elevation-step is positive here: higher surfaces @@ -14,9 +13,9 @@ --surface: 0.995; --elevation-step: 0.024; --contrast: 0.53; - --foreground-lightness: 0.16; - --muted-foreground-level: 0.68; - --tertiary-foreground-level: 0.48; + --foreground-lightness: 0.1; + --muted-foreground-level: 0.65; + --tertiary-foreground-level: 0.5; /* * Expressive fills run darker than the dark-theme anchors so they hold contrast * against the near-white surface (a light amber on white disappears). Lightness @@ -39,7 +38,7 @@ --secondary-default: 247.8deg 100% 70%; --secondary-400: 248.3deg 54.5% 25.9%; --secondary-200: 248deg 53.6% 11%; - --brand-link: 153.4deg 100% 36.7%; + --brand-link: 153.4deg 86.5% 28.3%; --brand-default: 152.9deg 60% 52.9%; --brand-600: 156.5deg 86.5% 26.1%; --brand-500: 155.3deg 78.4% 40%; From 5677b0ec2a49fe5d865ae0417e7047b9b5c04af7 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:56:03 +1000 Subject: [PATCH 07/54] chore(studio): clarify integration settings copy (#47578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Clarifies Vercel integration settings copy for org-scoped and project-scoped contexts. - Updates GitHub and Vercel integration section titles to sentence case for in-page headings. - Contributes to DEPR-565. | Before | After | | --- | --- | | Integrations Basket Supabase | Integrations Basket Supabase | | 18154 | Integrations Settings Agua
Basket Supabase | ## Summary by CodeRabbit ## Summary by CodeRabbit * **UI Text Updates** * Updated GitHub and Vercel headings and labels to use consistent casing (e.g., “GitHub connection”, “Vercel”). * Adjusted success and empty-state messaging for GitHub and Vercel integration actions. * **UX Improvements** * Improved GitHub and Vercel section descriptions by tailoring the text to project-scoped vs organization-scoped contexts. --- .../GitHubIntegrationConnectionForm.tsx | 6 +++--- .../GithubIntegration/GitHubStatus.tsx | 2 +- .../GithubIntegration/GithubSection.tsx | 20 +++++++++---------- .../VercelIntegration/VercelSection.tsx | 15 +++++++------- 4 files changed, 20 insertions(+), 23 deletions(-) diff --git a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx index f261fa21e9c..eb672742b98 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx @@ -102,7 +102,7 @@ export const GitHubIntegrationConnectionForm = ({ const { mutate: createConnection, isPending: isCreatingConnection } = useGitHubConnectionCreateMutation({ onSuccess: () => { - toast.success('GitHub integration successfully updated') + toast.success('GitHub connection updated') }, onError: (error) => { // Don't show error toast when connection already exists - the branch @@ -116,7 +116,7 @@ export const GitHubIntegrationConnectionForm = ({ const { mutateAsync: deleteConnection, isPending: isDeletingConnection } = useGitHubConnectionDeleteMutation({ onSuccess: () => { - toast.success('Successfully removed GitHub integration') + toast.success('GitHub connection removed') }, }) @@ -391,7 +391,7 @@ export const GitHubIntegrationConnectionForm = ({ { className="block w-full transition truncate text-sm text-foreground-light hover:text-foreground" >
-

GitHub Integration

+

GitHub connection

diff --git a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx index 8450bdee8d9..5bfd2dff6ff 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx @@ -80,7 +80,7 @@ export const GitHubSection = ({ isProjectScoped }: { isProjectScoped: boolean }) const { mutate: deleteGitHubConnection } = useGitHubConnectionDeleteMutation({ onSuccess: () => { - toast.success('Successfully deleted GitHub connection') + toast.success('GitHub connection deleted') }, }) @@ -97,6 +97,10 @@ export const GitHubSection = ({ isProjectScoped }: { isProjectScoped: boolean }) enabled: !isProjectScoped && canCreateGitHubConnection, }) + const description = isProjectScoped + ? 'Connect this Supabase project to a GitHub repository. Supabase applies database changes when you merge into your production branch. If branching is enabled, each pull request gets its own preview database.' + : 'Connect GitHub repositories to Supabase projects in this organization. The Supabase GitHub app watches file, branch, and pull request activity in each connected repository.' + const onDeleteGitHubConnection = useCallback( async (connection: IntegrationProjectConnection) => { if (!org?.id) { @@ -118,14 +122,8 @@ export const GitHubSection = ({ isProjectScoped }: { isProjectScoped: boolean })

- - {isProjectScoped ? 'GitHub Integration' : 'GitHub Connections'} - - - {isProjectScoped - ? 'Connect any of your GitHub repositories to a project. Supabase applies database changes when you merge into your production branch. If branching is enabled, each pull request gets its own preview database.' - : 'Connect any of your GitHub repositories to a project. The GitHub app watches file, branch, and pull request activity in your repository.'} - + GitHub + {description}
@@ -133,7 +131,7 @@ export const GitHubSection = ({ isProjectScoped }: { isProjectScoped: boolean }) {isLoadingPermissions ? ( ) : !canReadGitHubConnection ? ( - + ) : isProjectScoped ? ( ) : ( @@ -162,7 +160,7 @@ export const GitHubSection = ({ isProjectScoped }: { isProjectScoped: boolean }) {gitHubAuthorization && (

- You are authorized with the Supabase GitHub App. You can configure your{' '} + You are authorized with the Supabase GitHub app. You can configure your{' '} GitHub App installations and repository access diff --git a/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx b/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx index 7e0c4a775ff..5b92a1e6e78 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx @@ -138,6 +138,9 @@ export const VercelSection = ({ isProjectScoped }: { isProjectScoped: boolean }) connections.length, 'connection' )} ` + const description = isProjectScoped + ? 'Connect Vercel projects to this Supabase project. Supabase keeps environment variables up to date in each connected Vercel project.' + : 'Connect your Vercel teams to this Supabase organization. Supabase keeps environment variables up to date in each connected project. You can also link multiple Vercel projects to the same Supabase project.' return ( @@ -145,12 +148,8 @@ export const VercelSection = ({ isProjectScoped }: { isProjectScoped: boolean })

- Vercel Integration - - Connect your Vercel teams to your Supabase organization. Supabase keeps environment - variables up to date in each assigned project. You can also link multiple Vercel - projects to the same Supabase project. - + Vercel + {description}
@@ -221,9 +220,9 @@ export const VercelSection = ({ isProjectScoped }: { isProjectScoped: boolean }) disabled={isBranch} href={integrationUrl} icon={} - disabledTooltip="Install Vercel Integration on your project's main branch" + disabledTooltip="Install the Vercel integration on your project's main branch" > - Install Vercel Integration + Install Vercel integration )}
From b3c98c11f825d52e700f0220e5836252d1d8d3f2 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 8 Jul 2026 12:10:59 +0800 Subject: [PATCH 08/54] Use Link component instead of native a element in marketplace settings (#47720) ## Context Just a tiny one to use `Link` instead of `a` tags to render links in Marketplace settings The `a` tags would otherwise drop the `/dashboard` URL prefix on staging and prod ## Summary by CodeRabbit * **Bug Fixes** * Improved navigation in the integrations resource group section by using app-native links for management actions, making links behave more consistently across the interface. --- .../Integration/ConnectedResourceGroupSection.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/studio/components/interfaces/Integrations/Integration/ConnectedResourceGroupSection.tsx b/apps/studio/components/interfaces/Integrations/Integration/ConnectedResourceGroupSection.tsx index b4d4a519d35..39e3e2aab3f 100644 --- a/apps/studio/components/interfaces/Integrations/Integration/ConnectedResourceGroupSection.tsx +++ b/apps/studio/components/interfaces/Integrations/Integration/ConnectedResourceGroupSection.tsx @@ -1,4 +1,5 @@ import { Settings, Trash2, TriangleAlert } from 'lucide-react' +import Link from 'next/link' import { Badge, Button } from 'ui' import { Admonition } from 'ui-patterns/admonition' @@ -46,7 +47,7 @@ export const ResourceGroupSection = ({ group.manageAction && (
) @@ -69,7 +70,7 @@ export const ResourceGroupSection = ({
{group.manageAction && ( )} {linkTooltip ? {linkTooltip} : null} diff --git a/packages/ui-patterns/src/form/Layout/FormLayout.tsx b/packages/ui-patterns/src/form/Layout/FormLayout.tsx index 338daf0b1bb..b6db8bcaec2 100644 --- a/packages/ui-patterns/src/form/Layout/FormLayout.tsx +++ b/packages/ui-patterns/src/form/Layout/FormLayout.tsx @@ -318,12 +318,14 @@ export const FormLayout = React.forwardRef< {description} ) : description ? ( -

{description} -

+
) : null const LabelContents = () => ( From b10ed73d660c5df71ea8bfba24aac1355819f7f3 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Wed, 8 Jul 2026 15:57:07 +1000 Subject: [PATCH 10/54] Studio light background (#47722) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overrides bg on Studio just to give a bit more elevation ## Summary by CodeRabbit * **Style** * Updated the light theme’s surface styling by introducing a new theme value (`--surface`) to improve visual consistency across the app. --- apps/studio/styles/globals.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/studio/styles/globals.css b/apps/studio/styles/globals.css index 3b9726e0460..34b283dc339 100644 --- a/apps/studio/styles/globals.css +++ b/apps/studio/styles/globals.css @@ -158,6 +158,11 @@ --header-height: 3rem; } +[data-theme='light'], +.light { + --surface: 0.99; +} + [data-theme='dark'], .dark { --chart-1: hsl(var(--brand-default)); From 511a7806de9ac1622de06eea757fc6739b3221f0 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 8 Jul 2026 14:16:31 +0800 Subject: [PATCH 11/54] Joshen/fe 3789 unified logs filters click area is too small (#47675) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context Increases the click area of unified logs filter ### Before image ### After image ## Summary by CodeRabbit * **Style** * Improved spacing and alignment in filter checkbox rows. * Adjusted the expand/collapse control and “only” button positioning for a cleaner layout. * Refined nested option connector placement and sizing for better visual consistency. --- .../DataTableFilterCheckbox.tsx | 113 ++++++++++-------- 1 file changed, 62 insertions(+), 51 deletions(-) diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx index 69bf1912450..c575133cb4f 100644 --- a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx @@ -92,11 +92,11 @@ export function DataTableFilterCheckbox({ return (
({ />
) }) From fa20667ec1c2b95d17efc0bc1ab33ff7018d044f Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:43:31 +1000 Subject: [PATCH 12/54] fix(studio): migrate email template reset dialog to async AlertDialog (#47705) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Bug fix / refactor. Resolves DEPR-573. ## What is the current behavior? `ResetTemplateDialog` (added in #45572) confirms the Auth email template reset using the old `AlertDialog` workaround: an `AlertDialogAction` with `asChild` + `event.preventDefault()` and a manual loading `Button`, driven by `mutate` plus inline callbacks. Reset failures are only reported via a toast from the mutation's default `onError`, so the error disappears from the dialog context. This predates #45960, which added first-class async handling to `AlertDialogAction` (promise-returning handlers, controlled `loading`, and `AlertDialogBody` for inline feedback). #45960 explicitly flagged `ResetTemplateDialog` as needing this follow-up migration. ## What is the new behavior? `ResetTemplateDialog` now uses the async `AlertDialogAction` pattern: - The confirm handler uses `mutateAsync` and returns the reset promise, so the dialog stays open with a loading state while the mutation is pending and closes only after it succeeds. - Reset failures surface inline via a destructive `Admonition` inside `AlertDialogBody`, and the mutation's toast-only error path is suppressed (`onError: () => {}`). The inline error clears when the dialog closes. - `Cancel` is disabled while the reset is in flight. - The `asChild` + `preventDefault()` workaround and the manual loading `Button` are removed; `loading={isResetting}` is retained for parent-controlled loading. This matches the established usage in `DisablePipelinesDialog` / `JitDbAccessDeleteDialog` and the design-system `alert-dialog-async-error` example. ## To test - [ ] Customise an Auth email template, click **Reset template**, confirm the dialog shows loading until the reset succeeds and then closes with the editor refreshed to the default subject/body. - [ ] In DevTools → Network, block `*/templates/*/reset`, click **Reset**, and confirm the dialog stays open with an inline destructive admonition and no toast. ## Summary by CodeRabbit * **Bug Fixes** * Improved email template reset error handling by showing reset failures inline in the confirmation dialog (with a destructive alert message). * The dialog remains open on reset failure so users can review the error and retry. * “Cancel” is disabled while resetting; success behavior and existing success toast behavior remain unchanged. * **Tests** * Updated reset mutation mock to use async behavior and added coverage for reset failure UI/error handling (including that error toasts are not triggered). --- .../EmailTemplates/ResetTemplateDialog.tsx | 56 +++++++++++++------ .../EmailTemplates/TemplateEditor.test.tsx | 23 +++++++- 2 files changed, 62 insertions(+), 17 deletions(-) diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/ResetTemplateDialog.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/ResetTemplateDialog.tsx index 6c54b1dd732..233e993f042 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/ResetTemplateDialog.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/ResetTemplateDialog.tsx @@ -5,6 +5,7 @@ import { toast } from 'sonner' import { AlertDialog, AlertDialogAction, + AlertDialogBody, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, @@ -14,6 +15,7 @@ import { AlertDialogTrigger, Button, } from 'ui' +import { Admonition } from 'ui-patterns/admonition' import { type AuthTemplate } from './EmailTemplates.types' import { getAuthTemplateType } from './EmailTemplates.utils' @@ -32,6 +34,7 @@ export const ResetTemplateDialog = ({ }) => { const { ref: projectRef } = useParams() const [open, setOpen] = useState(false) + const [error, setError] = useState(null) const { can: canUpdateConfig } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'custom_config_gotrue' @@ -40,25 +43,40 @@ export const ResetTemplateDialog = ({ const { id } = template const templateType = getAuthTemplateType(id) - const { mutate: resetAuthTemplate, isPending: isResetting } = useAuthTemplateResetMutation() + const { mutateAsync: resetAuthTemplate, isPending: isResetting } = useAuthTemplateResetMutation({ + onSuccess: (config) => { + toast.success('Email template reset to default') + onResetSuccess(config) + }, + onError: () => {}, + }) const resetTemplateToDefault = async () => { if (!projectRef) throw new Error('Project ref is required') if (!templateType) throw new Error('Template type is required') - resetAuthTemplate( - { projectRef, template: templateType }, - { - onSuccess: (config) => { - toast.success('Email template reset to default') - onResetSuccess(config) - }, - } - ) + setError(null) + + try { + await resetAuthTemplate({ projectRef, template: templateType }) + } catch (error) { + setError( + error instanceof Error + ? error.message + : 'An unknown error occurred while resetting the template' + ) + throw error + } } return ( - + { + setOpen(next) + if (!next) setError(null) + }} + > + +
+
+ )} {disablePrompts && ( { disabled={isChatInputDisabled} placeholder={ hasMessages - ? 'Ask a follow up question...' + ? isSupportChat + ? 'Share details so the assistant can help with your support request...' + : 'Ask a follow up question...' : (snap.sqlSnippets ?? [])?.length > 0 ? 'Ask a question or make a change...' - : 'Chat to Postgres...' + : isSupportChat + ? 'Describe your support issue...' + : 'Chat to Postgres...' } value={value} onValueChange={(e) => setValue(e.target.value)} diff --git a/apps/studio/components/ui/AIAssistantPanel/SupportAssistantSuccessCardContent.tsx b/apps/studio/components/ui/AIAssistantPanel/SupportAssistantSuccessCardContent.tsx index 454b995427b..462b958782a 100644 --- a/apps/studio/components/ui/AIAssistantPanel/SupportAssistantSuccessCardContent.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/SupportAssistantSuccessCardContent.tsx @@ -20,7 +20,11 @@ import type { SubmittedSupportRequest } from '@/components/interfaces/Support/Su import { NO_PROJECT_MARKER } from '@/components/interfaces/Support/SupportForm.utils' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { useTrack } from '@/lib/telemetry/track' -import { useAiAssistantStateSnapshot, type AiAssistantState } from '@/state/ai-assistant-state' +import { + useAiAssistantState, + useAiAssistantStateSnapshot, + type AiAssistantState, +} from '@/state/ai-assistant-state' import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' type SupportAssistantPreviewChat = AiAssistantState['chatInstances'][string] @@ -47,6 +51,7 @@ export function SupportAssistantSuccessCardContent({ }: SupportAssistantSuccessCardContentProps) { const hasAssistantContext = hasProjectScopedAssistantContext(request.projectRef) const aiAssistant = useAiAssistantStateSnapshot() + const aiAssistantState = useAiAssistantState() const { openSidebar } = useSidebarManagerSnapshot() const track = useTrack() const createdChatIdRef = useRef(null) @@ -79,7 +84,38 @@ export function SupportAssistantSuccessCardContent({ ) if (chatId) { - aiAssistant.selectChat(chatId) + // Tag the chat as a support chat on first engagement so its messages and + // lifecycle sync to Front. Gated on the click (rather than on chat creation) + // so chats the user never opens don't create Front conversations. + const chat = aiAssistantState.chats[chatId] + if (chat && !chat.supportMetadata) { + chat.supportMetadata = { + subject: request.subject, + category: request.category, + severity: request.severity, + organizationSlug: request.organizationSlug, + projectRef: request.projectRef, + library: request.library, + affectedServices: request.affectedServices, + allowSupportAccess: request.allowSupportAccess, + // Reuse the Front conversation created at submit so AI messages thread into it. + frontConversationId: request.frontConversationId, + threadRef: request.threadRef, + isSupportChat: true, + lifecycleStatus: 'bot_active', + lastSyncedMessageCount: 0, + isSyncing: false, + isLifecycleSyncing: false, + } + + // Flush any messages produced before the user engaged (the initial prompt + // and any assistant reply). Subsequent turns sync via the onFinish hook. + void import('@/state/ai-chat-front-sync') + .then(({ syncSupportChatToFront }) => syncSupportChatToFront(chatId, aiAssistantState)) + .catch(() => {}) + } + + aiAssistantState.selectChat(chatId) } openSidebar(SIDEBAR_KEYS.AI_ASSISTANT) } diff --git a/apps/studio/data/feedback/ai-chat-front-sync.ts b/apps/studio/data/feedback/ai-chat-front-sync.ts new file mode 100644 index 00000000000..c3275301419 --- /dev/null +++ b/apps/studio/data/feedback/ai-chat-front-sync.ts @@ -0,0 +1,71 @@ +import * as Sentry from '@sentry/nextjs' +import type { components } from 'api-types' + +import { post } from '@/data/fetchers' + +export type AiSupportStatus = 'bot_active' | 'escalated' | 'user_resolved' | 'bot_resolved' + +export type SyncConversationMessagesVariables = + components['schemas']['SyncConversationMessagesBody'] + +type SyncConversationMessagesResponse = components['schemas']['SyncConversationMessagesResponse'] + +type EscalateConversationVariables = components['schemas']['EscalateConversationBody'] + +type ResolveConversationVariables = components['schemas']['ResolveConversationBody'] + +type UpdateConversationLifecycleResponse = + components['schemas']['UpdateConversationLifecycleResponse'] + +/** + * Sync AI support chat messages to Front via the Platform API. + * + * These are fire-and-forget helpers: failures are reported to Sentry and return + * `null` so a Front outage never breaks the user's chat. The caller leaves its + * sync bookkeeping untouched on `null` and retries the same delta on the next + * turn (the server de-dupes by external_id). + */ +export async function syncConversationMessagesToFront( + variables: SyncConversationMessagesVariables +): Promise { + const { data, error } = await post('/platform/feedback/conversations/messages', { + body: variables, + }) + + if (error) { + Sentry.captureException(error) + return null + } + + return data +} + +export async function escalateConversationInFront( + variables: EscalateConversationVariables +): Promise { + const { data, error } = await post('/platform/feedback/conversations/escalation', { + body: variables, + }) + + if (error) { + Sentry.captureException(error) + return null + } + + return data +} + +export async function resolveConversationInFront( + variables: ResolveConversationVariables +): Promise { + const { data, error } = await post('/platform/feedback/conversations/resolve', { + body: variables, + }) + + if (error) { + Sentry.captureException(error) + return null + } + + return data +} diff --git a/apps/studio/data/feedback/support-ticket-send.ts b/apps/studio/data/feedback/support-ticket-send.ts index 37957dce243..941e0bebf0d 100644 --- a/apps/studio/data/feedback/support-ticket-send.ts +++ b/apps/studio/data/feedback/support-ticket-send.ts @@ -24,6 +24,9 @@ export type sendSupportTicketVariables = { dashboardSentryIssueId?: string dashboardLogs?: string dashboardStudioVersion?: string + // Stable Front thread_ref so the AI support chat can later be appended to the + // same Front conversation that this submission creates. + threadRef?: string } const RATE_LIMIT_FALLBACK_SECONDS = 60 @@ -44,6 +47,7 @@ export async function sendSupportTicket({ dashboardSentryIssueId, dashboardLogs, dashboardStudioVersion, + threadRef, }: sendSupportTicketVariables) { const { data, error, response } = await post('/platform/feedback/send', { body: { @@ -64,6 +68,7 @@ export async function sendSupportTicket({ dashboardSentryIssueId, dashboardLogs, dashboardStudioVersion, + threadRef, }, }) diff --git a/apps/studio/lib/ai/generate-assistant-response.ts b/apps/studio/lib/ai/generate-assistant-response.ts index 93f32fb6bf8..9679be6fb9b 100644 --- a/apps/studio/lib/ai/generate-assistant-response.ts +++ b/apps/studio/lib/ai/generate-assistant-response.ts @@ -30,6 +30,7 @@ export async function generateAssistantResponse({ chatId, chatName, allowTracing, + supportMode, userId, orgId, planId, @@ -48,6 +49,7 @@ export async function generateAssistantResponse({ chatId?: string chatName?: string allowTracing?: boolean + supportMode?: boolean userId?: string orgId?: number planId?: string @@ -121,6 +123,9 @@ export async function generateAssistantResponse({ const assistantContent = hasProjectContext ? `The user's current project is ${projectRef || 'unknown'}. Their available schemas are: ${schemasString}. The current chat name is: ${chatName || 'unnamed'}.` : undefined + const supportAssistantContent = supportMode + ? `This is an active support chat. Help the user while they wait for a human agent. Keep guidance practical and concise. If the user asks for a human, or if the issue cannot be safely resolved, call escalate_to_human with a short reason. Only call resolve_support_conversation after the user explicitly confirms the issue is resolved; otherwise keep helping.` + : undefined const systemMessage: SystemModelMessage = { role: 'system', @@ -137,6 +142,14 @@ export async function generateAssistantResponse({ }, ] : []), + ...(supportAssistantContent + ? [ + { + role: 'assistant' as const, + content: supportAssistantContent, + }, + ] + : []), ...(await convertToModelMessages(messages)), ] diff --git a/apps/studio/lib/ai/tool-filter.ts b/apps/studio/lib/ai/tool-filter.ts index d5f0d1b58a5..66cf66c5b3e 100644 --- a/apps/studio/lib/ai/tool-filter.ts +++ b/apps/studio/lib/ai/tool-filter.ts @@ -32,6 +32,8 @@ export const toolSetValidationSchema = z.record( 'execute_sql', 'deploy_edge_function', 'rename_chat', + 'escalate_to_human', + 'resolve_support_conversation', 'list_policies', // Fallback tools for self-hosted @@ -70,6 +72,8 @@ export const TOOL_CATEGORY_MAP: Record = { execute_sql: TOOL_CATEGORIES.UI, deploy_edge_function: TOOL_CATEGORIES.UI, rename_chat: TOOL_CATEGORIES.UI, + escalate_to_human: TOOL_CATEGORIES.UI, + resolve_support_conversation: TOOL_CATEGORIES.UI, search_docs: TOOL_CATEGORIES.UI, get_active_incidents: TOOL_CATEGORIES.UI, load_knowledge: TOOL_CATEGORIES.UI, diff --git a/apps/studio/lib/ai/tools/index.ts b/apps/studio/lib/ai/tools/index.ts index 295921d1c70..5907292062f 100644 --- a/apps/studio/lib/ai/tools/index.ts +++ b/apps/studio/lib/ai/tools/index.ts @@ -7,6 +7,7 @@ import { getIncidentTools } from './incident-tools' import { getMcpTools } from './mcp-tools' import { getSchemaTools } from './schema-tools' import { getStudioTools } from './studio-tools' +import { getSupportLifecycleTools } from './support-tools' import { AiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi' export const getTools = async ({ @@ -16,6 +17,7 @@ export const getTools = async ({ aiOptInLevel, accessToken, baseUrl, + supportMode, signal, }: { projectRef: string @@ -24,6 +26,7 @@ export const getTools = async ({ aiOptInLevel: AiOptInLevel accessToken?: string baseUrl?: string + supportMode?: boolean // Required: tools fetched from the remote MCP server hold an HTTP connection // that is closed when this signal aborts (i.e. when the request ends). signal: AbortSignal @@ -73,7 +76,8 @@ export const getTools = async ({ } // Filter all tools based on the (potentially modified) AI opt-in level - const filteredTools: ToolSet = filterToolsByOptInLevel(tools, aiOptInLevel) + const toolsWithSupport = supportMode ? { ...tools, ...getSupportLifecycleTools() } : tools + const filteredTools: ToolSet = filterToolsByOptInLevel(toolsWithSupport, aiOptInLevel) return filteredTools } diff --git a/apps/studio/lib/ai/tools/support-tools.ts b/apps/studio/lib/ai/tools/support-tools.ts new file mode 100644 index 00000000000..352875c369a --- /dev/null +++ b/apps/studio/lib/ai/tools/support-tools.ts @@ -0,0 +1,33 @@ +import { tool } from 'ai' +import { z } from 'zod' + +export const getSupportLifecycleTools = () => ({ + escalate_to_human: tool({ + description: + 'Escalate this support conversation to a human support agent when the assistant cannot resolve the issue.', + inputSchema: z.object({ + reason: z + .string() + .trim() + .min(1, 'Escalation reason is required') + .describe('A short reason that explains why this support chat should be escalated.'), + }), + execute: async () => { + return { status: 'Escalation request sent to client.' } + }, + }), + resolve_support_conversation: tool({ + description: + 'Mark this support conversation as resolved when the issue appears solved from the user perspective.', + inputSchema: z.object({ + summary: z + .string() + .trim() + .min(1, 'Resolution summary is required') + .describe('A concise summary of the fix or guidance that resolved the support request.'), + }), + execute: async () => { + return { status: 'Resolve request sent to client.' } + }, + }), +}) diff --git a/apps/studio/lib/api/generate-v4.test.ts b/apps/studio/lib/api/generate-v4.test.ts index 5110d04fe8a..e7326ca57d0 100644 --- a/apps/studio/lib/api/generate-v4.test.ts +++ b/apps/studio/lib/api/generate-v4.test.ts @@ -3,6 +3,7 @@ import { UIMessage } from 'ai' import { expect, test, vi } from 'vitest' import generateV4 from '../../pages/api/ai/sql/generate-v4' +import { getTools } from '@/lib/ai/tools' import { sanitizeMessagePart } from '@/lib/ai/tools/tool-sanitizer' vi.mock('@/lib/ai/tools/tool-sanitizer', () => ({ @@ -34,6 +35,7 @@ test('generateV4 calls the tool sanitizer', async () => { projectRef: 'test-project', connectionString: 'test-connection', orgSlug: 'test-org', + supportMode: true, }, on: vi.fn(), } @@ -84,6 +86,11 @@ test('generateV4 calls the tool sanitizer', async () => { await generateV4(mockReq as any, mockRes as any) expect(sanitizeMessagePart).toHaveBeenCalled() + expect(getTools).toHaveBeenCalledWith( + expect.objectContaining({ + supportMode: true, + }) + ) // The response 'close' event must be wired up so the remote MCP connection // opened in getTools is torn down when the stream finishes or the client drops expect(mockRes.on).toHaveBeenCalledWith('close', expect.any(Function)) diff --git a/apps/studio/pages/api/ai/sql/generate-v4.ts b/apps/studio/pages/api/ai/sql/generate-v4.ts index 1bbc78aa29b..aa48b2cea79 100644 --- a/apps/studio/pages/api/ai/sql/generate-v4.ts +++ b/apps/studio/pages/api/ai/sql/generate-v4.ts @@ -62,6 +62,7 @@ const requestBodySchema = z.object({ table: z.string().optional(), chatId: z.string().optional(), chatName: z.string().optional(), + supportMode: z.boolean().optional(), orgSlug: z.string().optional(), model: z.string().optional(), }) @@ -91,6 +92,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw chatId, chatName, model: rawRequestedModel, + supportMode, } = data const requestedModel: AssistantModelId | undefined = @@ -176,6 +178,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw aiOptInLevel, accessToken, baseUrl: getURL(), + supportMode, signal: abortController.signal, }) @@ -217,6 +220,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw projectIsSensitive, projectRegion, }), + supportMode, userId, orgId, planId, diff --git a/apps/studio/state/ai-assistant-state.tsx b/apps/studio/state/ai-assistant-state.tsx index 7c31bfef138..f7e59494d94 100644 --- a/apps/studio/state/ai-assistant-state.tsx +++ b/apps/studio/state/ai-assistant-state.tsx @@ -7,6 +7,7 @@ import { createContext, PropsWithChildren, useContext, useEffect, useState } fro import { v4 as uuidv4 } from 'uuid' import { proxy, ref, snapshot, subscribe, useSnapshot } from 'valtio' +import type { AiSupportStatus } from '@/data/feedback/ai-chat-front-sync' import { constructHeaders } from '@/data/fetchers' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { prepareMessagesForAPI } from '@/lib/ai/message-utils' @@ -25,12 +26,41 @@ export type SqlSnippet = string | { label: string; content: string } export type AssistantModel = AssistantModelId +export type SupportChatMetadata = { + subject: string + category: string + severity: string + organizationSlug?: string + projectRef?: string + library?: string + affectedServices?: string + allowSupportAccess: boolean + browserInformation?: string + frontConversationId?: string + // Front thread_ref shared with the submit-time conversation. Sent as the sync + // API `chatId` so AI messages thread into that same Front conversation. + threadRef?: string + isSupportChat: true + lifecycleStatus: AiSupportStatus + // A lifecycle transition requested before the Front conversation id existed + // (e.g. the assistant resolved the chat before the first message sync + // returned an id). Flushed by syncSupportChatToFront once the id is assigned. + pendingLifecycleStatus?: AiSupportStatus + lifecycleClosedAt?: string + lastSyncedMessageCount: number + // Guards message syncs; lifecycle syncs use `isLifecycleSyncing` so the two + // never block each other. + isSyncing: boolean + isLifecycleSyncing: boolean +} + type ChatSession = { id: string name: string messages: AssistantMessageType[] createdAt: Date updatedAt: Date + supportMetadata?: SupportChatMetadata } export type AiAssistantContext = { @@ -259,6 +289,7 @@ function createChatInstance( connectionString: state.context.connectionString, chatId: options.id, chatName: chat?.name, + supportMode: chat?.supportMetadata?.isSupportChat ?? false, orgSlug: state.context.orgSlug, context: state.context, model: state.model, @@ -273,6 +304,16 @@ function createChatInstance( return } + if (toolCall.toolName === 'escalate_to_human') { + state.setSupportLifecycleStatus(options.id, 'escalated') + return + } + + if (toolCall.toolName === 'resolve_support_conversation') { + state.setSupportLifecycleStatus(options.id, 'bot_resolved') + return + } + if (toolCall.toolName === 'rename_chat') { const { newName } = toolCall.input as { newName: string } @@ -301,6 +342,13 @@ function createChatInstance( } delete state.pendingSpanIds[options.id] } + + // Sync support chat messages to Front (fire-and-forget, dynamic import to avoid SSR issues) + if (chat?.supportMetadata) { + import('@/state/ai-chat-front-sync') + .then(({ syncSupportChatToFront }) => syncSupportChatToFront(options.id, state)) + .catch(() => {}) + } } }, }) @@ -374,6 +422,28 @@ export const createAiAssistantState = (): AiAssistantState => { return chatId }, + setSupportLifecycleStatus: (chatId: string, status: AiSupportStatus) => { + const chat = state.chats[chatId] + if (!chat?.supportMetadata) return + + chat.supportMetadata.lifecycleStatus = status + if (status !== 'bot_active') { + chat.supportMetadata.lifecycleClosedAt = new Date().toISOString() + } + + // No Front conversation yet (the initial message sync hasn't returned an + // id). Queue the transition so syncSupportChatToFront can flush it once the + // id is assigned, rather than silently dropping it. + if (!chat.supportMetadata.frontConversationId) { + chat.supportMetadata.pendingLifecycleStatus = status + return + } + + import('@/state/ai-chat-front-sync').then(({ syncSupportLifecycleToFront }) => { + syncSupportLifecycleToFront(chatId, state, status).catch(() => {}) + }) + }, + selectChat: (id: string) => { if (id !== state.activeChatId) { state.activeChatId = id @@ -493,6 +563,20 @@ export const createAiAssistantState = (): AiAssistantState => { ? storedModel : INITIAL_AI_ASSISTANT.model + // Reset sync guards on any support chats (can't be mid-sync after reload) + Object.values(state.chats).forEach((chat) => { + if (chat.supportMetadata) { + if (!chat.supportMetadata.isSupportChat) { + chat.supportMetadata.isSupportChat = true + } + if (!chat.supportMetadata.lifecycleStatus) { + chat.supportMetadata.lifecycleStatus = 'bot_active' + } + chat.supportMetadata.isSyncing = false + chat.supportMetadata.isLifecycleSyncing = false + } + }) + // Ensure an active chat exists after loading if (!state.activeChat) { const chatIds = Object.keys(state.chats) @@ -545,6 +629,7 @@ export type AiAssistantState = AiAssistantData & { Pick > ) => string + setSupportLifecycleStatus: (chatId: string, status: AiSupportStatus) => void selectChat: (id: string) => void deleteChat: (id: string) => void renameChat: (id: string, name: string) => void diff --git a/apps/studio/state/ai-chat-front-sync.ts b/apps/studio/state/ai-chat-front-sync.ts new file mode 100644 index 00000000000..b2c8575caee --- /dev/null +++ b/apps/studio/state/ai-chat-front-sync.ts @@ -0,0 +1,176 @@ +import { parseSupportAssistantPrompt } from '@/components/interfaces/Support/SupportAssistant.utils' +import { + escalateConversationInFront, + resolveConversationInFront, + syncConversationMessagesToFront, +} from '@/data/feedback/ai-chat-front-sync' +import type { AiSupportStatus } from '@/data/feedback/ai-chat-front-sync' +import type { AiAssistantState } from '@/state/ai-assistant-state' + +/** + * Extracts plain text content from AI SDK message parts. + * Messages use a parts array where text content is in `part.type === 'text'`. + */ +function extractTextFromMessage(message: { + parts?: Array<{ type: string; text?: string }> +}): string { + if (!message.parts) return '' + return message.parts + .filter((part) => part.type === 'text' && part.text) + .map((part) => part.text ?? '') + .join('\n') +} + +type SyncableChatMessage = { + id: string + role: string + content?: string + parts?: Array<{ type: string; text?: string }> +} + +/** + * Syncs unsynced support chat messages to Front. + * + * Called from the AI assistant state `onFinish` callback (fire-and-forget). + * Uses `isSyncing` flag to prevent concurrent syncs for the same chat. + * Uses `lastSyncedMessageCount` to only send new (delta) messages. + */ +export async function syncSupportChatToFront( + chatId: string, + state: AiAssistantState +): Promise { + const chat = state.chats[chatId] + if (!chat?.supportMetadata) return + + const { supportMetadata } = chat + + // Prevent concurrent syncs + if (supportMetadata.isSyncing) return + supportMetadata.isSyncing = true + + try { + const allMessages = chat.messages + // Snapshot the boundary now: `chat.messages` may grow while the sync is in + // flight (streaming finishing, a fast follow-up). Advancing the counter to the + // post-await length would skip those messages and drop them from Front. On + // failure we leave the counter untouched and retry the same delta — the server + // de-dupes by external_id (chatId:msg.id), so retries don't duplicate. + const syncedUpTo = allMessages.length + const unsyncedMessages = allMessages.slice(supportMetadata.lastSyncedMessageCount) + + if (unsyncedMessages.length === 0) return + + const isInitial = supportMetadata.lastSyncedMessageCount === 0 + + // Convert AI SDK messages to plain text format for the sync API + const messagesToSync = (unsyncedMessages as SyncableChatMessage[]) + .filter((msg) => msg.role === 'user' || msg.role === 'assistant') + .map((msg) => ({ + id: msg.id, + role: msg.role as 'user' | 'assistant', + content: extractTextFromMessage(msg) || msg.content || '', + })) + // The seeded first message is a full `...` block. On the + // initial sync we only want the user's actual message (the `` value) + // in Front, not the metadata block. Once the conversation already exists the + // metadata was already sent, so drop the block entirely (empty content is + // filtered out below). + .map((msg) => { + const parsed = parseSupportAssistantPrompt(msg.content) + if (!parsed) return msg + // When frontConversationId exists the user message was already sent via /feedback/send + return { + ...msg, + content: isInitial && !supportMetadata.frontConversationId ? (parsed.message ?? '') : '', + } + }) + .filter((msg) => msg.content.length > 0) + + if (messagesToSync.length === 0) return + + const result = await syncConversationMessagesToFront({ + // Thread on the shared Front thread_ref (from submit) so messages append to the + // pre-created conversation. Falls back to the studio chat id for chats that + // weren't created via the support form. + chatId: supportMetadata.threadRef ?? chatId, + subject: supportMetadata.subject, + messages: messagesToSync, + isInitial, + conversationId: supportMetadata.frontConversationId, + ...(isInitial && { + organizationSlug: supportMetadata.organizationSlug, + projectRef: supportMetadata.projectRef, + category: supportMetadata.category, + severity: supportMetadata.severity, + affectedServices: supportMetadata.affectedServices, + library: supportMetadata.library, + allowSupportAccess: supportMetadata.allowSupportAccess, + browserInformation: supportMetadata.browserInformation, + }), + }) + + if (result) { + // Update sync tracking + supportMetadata.lastSyncedMessageCount = syncedUpTo + + if (result.conversationId && !supportMetadata.frontConversationId) { + supportMetadata.frontConversationId = result.conversationId + } + + // Flush a lifecycle transition that was requested before the Front + // conversation id existed (e.g. the assistant resolved/escalated the chat + // before this first message sync returned an id). syncSupportLifecycleToFront + // uses its own guard, so this won't contend with the message-sync flag. + if (supportMetadata.frontConversationId && supportMetadata.pendingLifecycleStatus) { + const pending = supportMetadata.pendingLifecycleStatus + supportMetadata.pendingLifecycleStatus = undefined + void syncSupportLifecycleToFront(chatId, state, pending).catch(() => {}) + } + } + } finally { + supportMetadata.isSyncing = false + } +} + +export async function syncSupportLifecycleToFront( + chatId: string, + state: AiAssistantState, + aiSupportStatus: AiSupportStatus +): Promise { + const chat = state.chats[chatId] + if (!chat?.supportMetadata) return + + const { supportMetadata } = chat + if (!supportMetadata.frontConversationId) return + // Use a dedicated guard so an in-flight message sync (`isSyncing`) can never + // short-circuit a lifecycle transition. A dropped lifecycle call is never + // retried, unlike message syncs, so the two must not share a flag. + if (supportMetadata.isLifecycleSyncing) return + + supportMetadata.isLifecycleSyncing = true + + try { + let result = null + + const threadRef = supportMetadata.threadRef ?? chatId + + if (aiSupportStatus === 'escalated') { + result = await escalateConversationInFront({ + chatId: threadRef, + conversationId: supportMetadata.frontConversationId, + }) + } else if (aiSupportStatus === 'user_resolved' || aiSupportStatus === 'bot_resolved') { + result = await resolveConversationInFront({ + chatId: threadRef, + conversationId: supportMetadata.frontConversationId, + aiSupportStatus, + }) + } + + if (result?.aiSupportStatus) { + supportMetadata.lifecycleStatus = result.aiSupportStatus + } + } finally { + supportMetadata.isLifecycleSyncing = false + } +} From 6dfae09b6da67780751d9745d5f554b59883465e Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Wed, 8 Jul 2026 19:56:12 +0800 Subject: [PATCH 18/54] fix(www): postgres card art overlaps text column (#47734) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before Screenshot 2026-07-08 at 7 29 07 PM After Screenshot 2026-07-08 at 7 29 55 PM ## Summary On the homepage, the Postgres Database product card's elephant artwork renders over the card's description text at every viewport ≥1280px. I traced it to #47226 (merged June 24): standardizing the marketing container to `section-container` (`max-w-7xl` + `xl:px-24`) capped products-grid content at 1088px, shrinking the card to ~538px while its fixed geometry (250px text column + 398px right-anchored square artwork box) needs ~625px. Large monitors regressed hardest: before June 24 they got ~676px cards and no overlap. The app-router homepage move (#47228), the color system PR (#47288), and the artwork PNGs are all unrelated (verified against production DOM and full diffs). Two changes to the artwork span in `DatabaseVisual.tsx`: 1. Cap the box width at `calc(100% - 280px)` from `md` up, where 280 covers the text column's `md:max-w-[250px]` cap (in `ProductCard.tsx`'s `isDatabase` branch) plus card padding and breathing room. The art scales down through its existing `object-contain`. At md/lg the card is full-width (`md:col-span-12`), so the clamp never binds and rendering is unchanged there. 2. Remove the `xl:-right-12` bleed (base `right-0` stands, matching how 2xl already rendered). The bleed used to clip only the art's transparent canvas margin; with the clamped box the art fills its full width, so the 48px offset was cropping the elephant itself at 1280-1535px. The hover line-art SVG scales with the box and stays aligned with the PNG (identical aspect ratios: viewBox 390:430, PNG 585x645). ## Changes - Add `md:max-w-[calc(100%-280px)]` to the artwork span in `DatabaseVisual.tsx` so the Postgres card art can never cross its text column - Drop `xl:-right-12 2xl:right-0` from the same span so the smaller art isn't clipped at the card's right edge ## Testing Round 1 on the Vercel preview (commit e1ca671, measured via getBoundingClientRect + computed styles): - [x] Art clear of the text column at 1280/1440/1600/1920 (gap 54px at 1280/1440, 6px at 1600/1920); computed `max-width: calc(100% - 280px)` applies - [x] Hover at 1440 — SVG and PNG rects identical (pixel-exact alignment) - [x] 768/1024 — full-width card unchanged, span still a 398px square (clamp resolves but doesn't bind) - [x] Light theme at 1440 — same geometry, no overlap Round 1 also surfaced that the `xl:-right-12` bleed now cropped the elephant at 1280-1535px widths → second commit removes it. Round 2 (commit e28b408): - [x] 1280/1440/1512/1600/1920 — art fully visible (span right edge flush at the card's inner edge, e.g. 713 vs 714 at 1440), still clear of the text column (span left 457 vs text right 451) - [x] 768/1024 — unchanged (span still a right-anchored 398px square) - [x] Hover alignment still exact (PNG and SVG rects identical: 457,452,256,398) ## Linear - fixes GROWTH-971 --- apps/www/components/Products/DatabaseVisual.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/www/components/Products/DatabaseVisual.tsx b/apps/www/components/Products/DatabaseVisual.tsx index b53a7d79d51..71c1bebdcd2 100644 --- a/apps/www/components/Products/DatabaseVisual.tsx +++ b/apps/www/components/Products/DatabaseVisual.tsx @@ -60,7 +60,7 @@ const DatabaseVisual: React.FC = ({ className, hasGlow = true }) => { role="img" aria-label="Supabase Postgres database visual composition" > - + Supabase Postgres database Date: Wed, 8 Jul 2026 13:03:14 +0100 Subject: [PATCH 19/54] Add Shane Adams to humans.txt (#47701) Adding myself as part of onboarding ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? doc update: added name to humans.txt ## Summary by CodeRabbit * **Documentation** * Added a new team member entry in the public site metadata. --- apps/docs/public/humans.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index 961c9c6f3db..e2c713b8a4d 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -264,6 +264,7 @@ Sean Romberg Sean Thompson Sergio Cioban Filho Shaii O +Shane Adams Shane E Shreekar Shetty Sreyas Udayavarman From fa11c1c74c2c3548c1f0de5cf97a1fec86703798 Mon Sep 17 00:00:00 2001 From: Yogeshwaran C <84272111+yogeshwaran-c@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:00:23 +0530 Subject: [PATCH 20/54] docs: fix outdated supabase.io URLs in example READMEs (#42830) --- .../realtime/flutter-multiplayer-shooting-game/README.md | 2 +- examples/realtime/nextjs-auth-presence/README.md | 6 +++--- examples/storage/resumable-upload-signed-uppy/README.md | 2 +- .../user-management/flutter-user-management/README.md | 8 ++++---- examples/user-management/nuxt3-user-management/README.md | 8 ++++---- examples/user-management/swift-user-management/README.md | 6 +++--- examples/user-management/vue3-user-management/README.md | 8 ++++---- 7 files changed, 20 insertions(+), 20 deletions(-) diff --git a/examples/realtime/flutter-multiplayer-shooting-game/README.md b/examples/realtime/flutter-multiplayer-shooting-game/README.md index f1ed613916d..7a052da6f30 100644 --- a/examples/realtime/flutter-multiplayer-shooting-game/README.md +++ b/examples/realtime/flutter-multiplayer-shooting-game/README.md @@ -8,7 +8,7 @@ You can find a step by step guide on how to build this app on [How to build a re ### 1. Create new project -Sign up to Supabase - [app.supabase.io](https://app.supabase.io) and create a new project. Wait for your database to start. +Sign up to Supabase - [supabase.com/dashboard](https://supabase.com/dashboard) and create a new project. Wait for your database to start. ### 2. Get the URL and Key diff --git a/examples/realtime/nextjs-auth-presence/README.md b/examples/realtime/nextjs-auth-presence/README.md index 1033c44b350..23867fa9c55 100644 --- a/examples/realtime/nextjs-auth-presence/README.md +++ b/examples/realtime/nextjs-auth-presence/README.md @@ -5,9 +5,9 @@ User get authenticated using Supabase Auth API. Once Logged-in you can see which - Frontend: - Next.js. - - [Supabase.js v2 (realtime presence support)](https://supabase.io/docs/library/getting-started) + - [Supabase.js v2 (realtime presence support)](https://supabase.com/docs/reference/javascript/introduction) - Backend: - - [app.supabase.io](https://app.supabase.io/): hosted postgres database with realtime support. + - [supabase.com/dashboard](https://supabase.com/dashboard): hosted postgres database with realtime support. ## Real time Presence APIs used. @@ -17,7 +17,7 @@ This program shows usage of channel presence API calls such as , channel.on('pre ### 1. Create new project -Sign up to Supabase - [https://app.supabase.io](https://app.supabase.io) and create a new project. Wait for your database to start. +Sign up to Supabase - [https://supabase.com/dashboard](https://supabase.com/dashboard) and create a new project. Wait for your database to start. ### 2. Run "User Management Starter" Quickstart diff --git a/examples/storage/resumable-upload-signed-uppy/README.md b/examples/storage/resumable-upload-signed-uppy/README.md index 44390a4d2c3..ea5610e018e 100644 --- a/examples/storage/resumable-upload-signed-uppy/README.md +++ b/examples/storage/resumable-upload-signed-uppy/README.md @@ -1,6 +1,6 @@ ## Resumable Uploads with Supabase Storage and Uppy -This example shows how to use signed urls from [Supabase Storage](https://supabase.io/docs/reference/javascript/storage) with [Uppy](https://uppy.io/) to upload files to Supabase Storage using the TUS protocol (signed resumable uploads). +This example shows how to use signed urls from [Supabase Storage](https://supabase.com/docs/reference/javascript/storage) with [Uppy](https://uppy.io/) to upload files to Supabase Storage using the TUS protocol (signed resumable uploads). This works by calling `createSignedUploadUrl()` to get a token for each file, and passing that token via the `x-signature` header when uploading the files diff --git a/examples/user-management/flutter-user-management/README.md b/examples/user-management/flutter-user-management/README.md index 0f0ef3768d0..e2eb49e1eac 100644 --- a/examples/user-management/flutter-user-management/README.md +++ b/examples/user-management/flutter-user-management/README.md @@ -1,18 +1,18 @@ # Supabase Flutter User Management -This repo is a quick sample of how you can get started building apps using Flutter and Supabase. You can find a step by step guide of how to build out this app in the [Quickstart: Flutter guide](https://supabase.io/docs/guides/with-flutter). +This repo is a quick sample of how you can get started building apps using Flutter and Supabase. You can find a step by step guide of how to build out this app in the [Quickstart: Flutter guide](https://supabase.com/docs/guides/with-flutter). This repo will demonstrate how to: - sign users in with Supabase Auth using [magic link](https://supabase.com/docs/reference/dart/auth-signinwithotp) -- store and retrieve data with [Supabase database](https://supabase.io/docs/guides/database) -- store image files in [Supabase storage](https://supabase.io/docs/guides/storage) +- store and retrieve data with [Supabase database](https://supabase.com/docs/guides/database) +- store image files in [Supabase storage](https://supabase.com/docs/guides/storage) Supabase User Management example ## Getting Started -Rename `.env.example` to `.env` and fill in [your Supabase credentials](https://supabase.io/docs/guides/with-flutter#get-the-api-keys). +Rename `.env.example` to `.env` and fill in [your Supabase credentials](https://supabase.com/docs/guides/with-flutter#get-the-api-keys). You can run this app on iOS, Android or the Web. diff --git a/examples/user-management/nuxt3-user-management/README.md b/examples/user-management/nuxt3-user-management/README.md index 16e25808146..2a53504e8ad 100644 --- a/examples/user-management/nuxt3-user-management/README.md +++ b/examples/user-management/nuxt3-user-management/README.md @@ -1,16 +1,16 @@ # Supabase Nuxt User Management -This repo is a quick sample of how you can get started building apps using Nuxt 3 and Supabase. You can find a step by step guide of how to build out this app in the [Quickstart: Nuxt guide](https://supabase.io/docs/guides/with-nuxt-3). +This repo is a quick sample of how you can get started building apps using Nuxt 3 and Supabase. You can find a step by step guide of how to build out this app in the [Quickstart: Nuxt guide](https://supabase.com/docs/guides/with-nuxt-3). This repo will demonstrate how to: - sign users in with Supabase Auth using [magic link](https://supabase.com/docs/reference/dart/auth-signinwithotp) -- store and retrieve data with [Supabase database](https://supabase.io/docs/guides/database) -- store image files in [Supabase storage](https://supabase.io/docs/guides/storage) +- store and retrieve data with [Supabase database](https://supabase.com/docs/guides/database) +- store image files in [Supabase storage](https://supabase.com/docs/guides/storage) ## Getting Started -Before running this app, you need to create a Supabase project and copy [your credentials](https://supabase.io/docs/guides/with-nuxt-3#get-the-api-keys) to `.env`. +Before running this app, you need to create a Supabase project and copy [your credentials](https://supabase.com/docs/guides/with-nuxt-3#get-the-api-keys) to `.env`. Run the following command to launch it on `localhost:3000` diff --git a/examples/user-management/swift-user-management/README.md b/examples/user-management/swift-user-management/README.md index 88fc1ea5f5d..04dc62cbf2d 100644 --- a/examples/user-management/swift-user-management/README.md +++ b/examples/user-management/swift-user-management/README.md @@ -5,14 +5,14 @@ This repo is a quick sample of how you can get started building apps using Swift This repo will demonstrate how to: - Sign users in with Supabase Auth using [magic link](https://supabase.com/docs/reference/dart/auth-signinwithotp) -- Store and retrieve data with [Supabase database](https://supabase.io/docs/guides/database) -- Store image files in [Supabase storage](https://supabase.io/docs/guides/storage) +- Store and retrieve data with [Supabase database](https://supabase.com/docs/guides/database) +- Store image files in [Supabase storage](https://supabase.com/docs/guides/storage) ![Supabase User Management example](supabase-swift-demo.png) ## Getting Started -Run `cp .env.example .env` and fill in [your credentials](https://supabase.io/docs/guides/with-flutter#get-the-api-keys). +Run `cp .env.example .env` and fill in [your credentials](https://supabase.com/docs/guides/with-flutter#get-the-api-keys). Run the application in a device or simulator using Xcode. diff --git a/examples/user-management/vue3-user-management/README.md b/examples/user-management/vue3-user-management/README.md index 8f85e2a6e0c..e70a2489c15 100644 --- a/examples/user-management/vue3-user-management/README.md +++ b/examples/user-management/vue3-user-management/README.md @@ -1,16 +1,16 @@ # Supabase Vue 3 User Management -This repo is a quick sample of how you can get started building apps using Vue 3 and Supabase. You can find a step by step guide of how to build out this app in the [Quickstart: Vue guide](https://supabase.io/docs/guides/with-vue-3). +This repo is a quick sample of how you can get started building apps using Vue 3 and Supabase. You can find a step by step guide of how to build out this app in the [Quickstart: Vue guide](https://supabase.com/docs/guides/with-vue-3). This repo will demonstrate how to: - sign users in with Supabase Auth using [magic link](https://supabase.com/docs/reference/dart/auth-signinwithotp) -- store and retrieve data with [Supabase database](https://supabase.io/docs/guides/database) -- store image files in [Supabase storage](https://supabase.io/docs/guides/storage) +- store and retrieve data with [Supabase database](https://supabase.com/docs/guides/database) +- store image files in [Supabase storage](https://supabase.com/docs/guides/storage) ## Getting Started -Before running this app, you need to create a Supabase project and copy [your credentials](https://supabase.io/docs/guides/with-vue-3#get-the-api-keys) to `.env`. +Before running this app, you need to create a Supabase project and copy [your credentials](https://supabase.com/docs/guides/with-vue-3#get-the-api-keys) to `.env`. Run the following command to launch it on `localhost:5173` From 9519be0fa3fb1318f05d077b8fbff566a119eb52 Mon Sep 17 00:00:00 2001 From: Jeremias Menichelli Date: Wed, 8 Jul 2026 14:37:34 +0200 Subject: [PATCH 21/54] chore: Add docs team as CODEOWNERS (#47733) --- .github/CODEOWNERS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c494d51aea7..3450bcab20b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -8,6 +8,8 @@ /apps/studio/ @supabase/Dashboard +/apps/docs/ @supabase/docs + /apps/www/ @supabase/marketing /apps/www/public/images/blog @supabase/marketing /apps/www/lib/redirects.js From 6e4dc5df754fb748858de065411df400f36e1fba Mon Sep 17 00:00:00 2001 From: Yarema Kertytsky <83857001+ykertytsky@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:42:38 +0300 Subject: [PATCH 22/54] fix: correct typos and improve clarity in AI documentation (#42662) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Hello team and community! Decided that I want to start helping to maintain supabase, and decided to open my first PR with clearing typos and phrasing improvements for docs in AI folder. ## What is the current behavior? Please link any relevant issues here. ## What is the new behavior? Feel free to include screenshots if it includes visual changes. ## Additional context Add any other context or screenshots. ## Summary by CodeRabbit * **Documentation** * Refined and corrected grammar throughout AI implementation guides, improving readability across production deployment, Google Colab integration, LangChain, RAG with permissions, semantic search, and vector columns documentation. Updates include terminology consistency improvements, punctuation refinements, and clearer phrasing to enhance overall guide clarity. --------- Co-authored-by: Chris Chinchilla --- apps/docs/content/guides/ai/going-to-prod.mdx | 4 ++-- apps/docs/content/guides/ai/google-colab.mdx | 2 +- apps/docs/content/guides/ai/langchain.mdx | 4 ++-- apps/docs/content/guides/ai/rag-with-permissions.mdx | 6 +++--- apps/docs/content/guides/ai/semantic-search.mdx | 6 +++--- apps/docs/content/guides/ai/vector-columns.mdx | 6 +++--- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/docs/content/guides/ai/going-to-prod.mdx b/apps/docs/content/guides/ai/going-to-prod.mdx index 0673f11d211..e152ee3232a 100644 --- a/apps/docs/content/guides/ai/going-to-prod.mdx +++ b/apps/docs/content/guides/ai/going-to-prod.mdx @@ -6,7 +6,7 @@ subtitle: 'Going to production checklist for AI applications.' sidebar_label: 'Going to Production' --- -This guide will help you to prepare your application for production. We'll provide actionable steps to help you scale your application, ensure that it is reliable, can handle the load, and provide optimal accuracy for your use case. +This guide helps you prepare your application for production. It provides actionable steps to help you scale your application, ensure that it is reliable, can handle the load, and provide optimal accuracy for your use case. See our [Engineering for Scale](/docs/guides/ai/engineering-for-scale) guide for more information about engineering at scale. @@ -72,7 +72,7 @@ The values of lists and probes directly affect accuracy and queries per second ( - Higher `probes` means that select queries will be slower, but you can achieve better accuracy. - `lists` and `probes` are not independent. Higher `lists` means that you will have to use higher `probes` to achieve the same accuracy. -You can find more examples of how `lists` and `probes` constants affect accuracy and QPS in [pgvector 0.4.0 performance](/blog/pgvector-performance) blogpost. +You can find more examples of how `lists` and `probes` constants affect accuracy and QPS in [pgvector 0.4.0 performance](/blog/pgvector-performance) blog post. The chart below shows how the IVFFlat lists count affects accuracy and queries-per-second. diff --git a/apps/docs/content/guides/ai/google-colab.mdx b/apps/docs/content/guides/ai/google-colab.mdx index 8c0a22406a5..837e3a53c17 100644 --- a/apps/docs/content/guides/ai/google-colab.mdx +++ b/apps/docs/content/guides/ai/google-colab.mdx @@ -27,7 +27,7 @@ Start by visiting [colab.research.google.com](https://colab.research.google.com/ We'll use the Supabase Vector client, [Vecs](/docs/guides/ai/vecs-python-client), to manage our collections. -At the top of the notebook add the notebook paste the following code and hit the "execute" button (`ctrl+enter`): +At the top of the notebook, paste the following code and click "Execute" (`ctrl+enter`): ```py pip install vecs diff --git a/apps/docs/content/guides/ai/langchain.mdx b/apps/docs/content/guides/ai/langchain.mdx index 09cf1c570fa..7fbb83ba4ba 100644 --- a/apps/docs/content/guides/ai/langchain.mdx +++ b/apps/docs/content/guides/ai/langchain.mdx @@ -9,7 +9,7 @@ sidebar_label: 'LangChain' ## Initializing your database -Prepare you database with the relevant tables: +Prepare your database with the relevant tables: { LangChain supports the concept of a hybrid search, which combines Similarity Search with Full Text Search. Read the official docs to get started: [Supabase Hybrid Search](https://js.langchain.com/docs/modules/indexes/retrievers/supabase-hybrid). -You can install the LangChain Hybrid Search function though our [database.dev package manager](https://database.dev/langchain/hybrid_search). +You can install the LangChain Hybrid Search function through our [database.dev package manager](https://database.dev/langchain/hybrid_search). ## Resources diff --git a/apps/docs/content/guides/ai/rag-with-permissions.mdx b/apps/docs/content/guides/ai/rag-with-permissions.mdx index da34eb5353d..9ecd87c69ae 100644 --- a/apps/docs/content/guides/ai/rag-with-permissions.mdx +++ b/apps/docs/content/guides/ai/rag-with-permissions.mdx @@ -1,12 +1,12 @@ --- id: 'ai-rag-with-permissions' title: 'RAG with Permissions' -subtitle: 'Fine-grain access control with Retrieval Augmented Generation.' -description: 'Implement fine-grain access control with retrieval augmented generation' +subtitle: 'Fine-grained access control with Retrieval Augmented Generation.' +description: 'Implement fine-grained access control with retrieval augmented generation' sidebar_label: 'RAG with Permissions' --- -Since pgvector is built on top of Postgres, you can implement fine-grain access control on your vector database using [Row Level Security (RLS)](/docs/guides/database/postgres/row-level-security). This means you can restrict which documents are returned during a vector similarity search to users that have access to them. Supabase also supports [Foreign Data Wrappers (FDW)](/docs/guides/database/extensions/wrappers/overview) which means you can use an external database or data source to determine these permissions if your user data doesn't exist in Supabase. +Since pgvector is built on top of Postgres, you can implement fine-grained access control on your vector database using [Row Level Security (RLS)](/docs/guides/database/postgres/row-level-security). This means you can restrict which documents are returned during a vector similarity search to users that have access to them. Supabase also supports [Foreign Data Wrappers (FDW)](/docs/guides/database/extensions/wrappers/overview) which means you can use an external database or data source to determine these permissions if your user data doesn't exist in Supabase. Use this guide to learn how to restrict access to documents when performing retrieval augmented generation (RAG). diff --git a/apps/docs/content/guides/ai/semantic-search.mdx b/apps/docs/content/guides/ai/semantic-search.mdx index 76f77ac3582..6146b6a5edd 100644 --- a/apps/docs/content/guides/ai/semantic-search.mdx +++ b/apps/docs/content/guides/ai/semantic-search.mdx @@ -26,7 +26,7 @@ Embeddings are generated using a language model, and embeddings are compared to ## Embedding models -There are many embedding models available today. Supabase Edge Functions has [built in support](/docs/guides/functions/examples/semantic-search) for the `gte-small` model. Others can be accessed through third-party APIs like [OpenAI](https://platform.openai.com/docs/guides/embeddings), where you send your text in the request and receive an embedding vector in the response. Others can run locally on your own compute, such as through Transformers.js for JavaScript implementations. For more information on local implementation, see [Generate embeddings](/docs/guides/ai/quickstarts/generate-text-embeddings). +There are many embedding models available today. Supabase Edge Functions has [built-in support](/docs/guides/functions/examples/semantic-search) for the `gte-small` model. Others can be accessed through third-party APIs like [OpenAI](https://platform.openai.com/docs/guides/embeddings), where you send your text in the request and receive an embedding vector in the response. Others can run locally on your own compute, such as through Transformers.js for JavaScript implementations. For more information on local implementation, see [Generate embeddings](/docs/guides/ai/quickstarts/generate-text-embeddings). It's crucial to remember that when using embedding models with semantic search, you must use the same model for all embedding comparisons. Comparing embeddings created by different models will yield meaningless results. @@ -65,7 +65,7 @@ For more details on vector columns, including how to generate embeddings and sto ### Similarity metric -`pgvector` support 3 operators for computing distance between embeddings: +`pgvector` supports 3 operators for computing distance between embeddings: | **Operator** | **Description** | | ------------ | ---------------------- | @@ -143,7 +143,7 @@ You can also call this method directly from SQL: select * from match_documents( '[...]'::extensions.vector(512), -- pass the query embedding - 0.78, -- chose an appropriate threshold for your data + 0.78, -- choose an appropriate threshold for your data 10 -- choose the number of matches ); ``` diff --git a/apps/docs/content/guides/ai/vector-columns.mdx b/apps/docs/content/guides/ai/vector-columns.mdx index d9797eed146..d3ab41c3aa9 100644 --- a/apps/docs/content/guides/ai/vector-columns.mdx +++ b/apps/docs/content/guides/ai/vector-columns.mdx @@ -5,7 +5,7 @@ description: 'Learn how to use vectors within your own Postgres tables' sidebar_label: 'Vector columns' --- -Supabase offers a number of different ways to store and query vectors within Postgres. The SQL included in this guide is applicable for clients in all programming languages. If you are a Python user see your [Python client options](/docs/guides/ai/python-clients) after reading the `Learn` section. +Supabase offers a number of different ways to store and query vectors within Postgres. The SQL included in this guide is applicable for clients in all programming languages. If you are a Python user, see your [Python client options](/docs/guides/ai/python-clients) after reading the `Learn` section. Vectors in Supabase are enabled via [pgvector](https://github.com/pgvector/pgvector/), a Postgres extension for storing and querying vectors in Postgres. It can be used to store [embeddings](/docs/guides/ai/concepts#what-are-embeddings). @@ -48,7 +48,7 @@ To disable an extension, call `drop extension`. ### Create a table to store vectors -After enabling the `vector` extension, you will get access to a new data type called `vector`. The size of the vector (indicated in parenthesis) represents the number of dimensions stored in that vector. +After enabling the `vector` extension, you will get access to a new data type called `vector`. The size of the vector (indicated in parentheses) represents the number of dimensions stored in that vector. ```sql create table documents ( @@ -100,7 +100,7 @@ This example uses the JavaScript Supabase client, but you can modify it to work ### Querying a vector / embedding -Similarity search is the most common use case for vectors. `pgvector` support 3 new operators for computing distance: +Similarity search is the most common use case for vectors. `pgvector` supports 3 new operators for computing distance: | Operator | Description | | -------- | ---------------------- | From 62160939a8f586660bccf39f415fecaefd8635c4 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:52:36 +0200 Subject: [PATCH 23/54] fix: make status hovercard trigger on focus (#47731) ## Problem Status lists only appear on mouse hover and disappear when panning at 200%+ zoom. ## Solution Make hover-triggered information available via click or focus in line with WCAG 1.4.13 ## Summary by CodeRabbit * **Bug Fixes** * Improved keyboard accessibility for the service status hover card by making the trigger focusable. --- apps/studio/components/interfaces/ProjectHome/ServiceStatus.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/studio/components/interfaces/ProjectHome/ServiceStatus.tsx b/apps/studio/components/interfaces/ProjectHome/ServiceStatus.tsx index 079ba9eaf09..369eb5d88f7 100644 --- a/apps/studio/components/interfaces/ProjectHome/ServiceStatus.tsx +++ b/apps/studio/components/interfaces/ProjectHome/ServiceStatus.tsx @@ -307,7 +307,7 @@ export const ServiceStatus = () => { return ( - + Date: Wed, 8 Jul 2026 20:52:42 +0800 Subject: [PATCH 24/54] Flip show tooltip to true for supavisor connections chart (#47730) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context Realised that tooltips were not showing up for supavisor charts in database reports - just needed to flip a boolean Although - i don't have any projects with supavisor connections data (even on prod) so I can't visually verify this atm Also fixes a small issue in which docs url for the chart wasn't showing if the chart had no data, e.g: image Should have docs button like this image ## Summary by CodeRabbit * **New Features** * Enabled tooltips for the “Shared Pooler (Supavisor) client connections” chart so the metric can be inspected directly. * **UI Improvements** * Adjusted the tooltip positioning in the chart header for clearer readability. * When charts have no data, the “Learn more”/documentation link now follows the provided docs URL. --------- Co-authored-by: Ali Waseem --- apps/studio/components/ui/Charts/ChartHeader.tsx | 2 +- apps/studio/components/ui/Charts/ComposedChart.tsx | 1 + apps/studio/components/ui/Charts/NoDataPlaceholder.tsx | 3 +++ apps/studio/data/reports/database-charts.ts | 2 +- 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/studio/components/ui/Charts/ChartHeader.tsx b/apps/studio/components/ui/Charts/ChartHeader.tsx index 8a6e276819e..d896e2f92b2 100644 --- a/apps/studio/components/ui/Charts/ChartHeader.tsx +++ b/apps/studio/components/ui/Charts/ChartHeader.tsx @@ -195,7 +195,7 @@ export const ChartHeader = ({ - + {titleTooltip} {docsUrl && ( <> diff --git a/apps/studio/components/ui/Charts/ComposedChart.tsx b/apps/studio/components/ui/Charts/ComposedChart.tsx index f7567480aa5..a539204af78 100644 --- a/apps/studio/components/ui/Charts/ComposedChart.tsx +++ b/apps/studio/components/ui/Charts/ComposedChart.tsx @@ -424,6 +424,7 @@ export function ComposedChart({ className={className} attribute={title} format={format} + docsUrl={docsUrl} titleTooltip={titleTooltip} /> ) diff --git a/apps/studio/components/ui/Charts/NoDataPlaceholder.tsx b/apps/studio/components/ui/Charts/NoDataPlaceholder.tsx index a876a59cf71..c09ad190ded 100644 --- a/apps/studio/components/ui/Charts/NoDataPlaceholder.tsx +++ b/apps/studio/components/ui/Charts/NoDataPlaceholder.tsx @@ -15,6 +15,7 @@ interface NoDataPlaceholderProps { isFullHeight?: boolean titleTooltip?: string hideTotalPlaceholder?: boolean + docsUrl?: string } const NoDataPlaceholder = ({ attribute, @@ -26,6 +27,7 @@ const NoDataPlaceholder = ({ isFullHeight = false, titleTooltip, hideTotalPlaceholder = false, + docsUrl, }: NoDataPlaceholderProps) => { const { minHeight } = useChartSize(size) @@ -37,6 +39,7 @@ const NoDataPlaceholder = ({ format={format} highlightedValue={hideTotalPlaceholder ? undefined : 0} titleTooltip={titleTooltip} + docsUrl={docsUrl} /> )}
Date: Wed, 8 Jul 2026 18:32:24 +0530 Subject: [PATCH 25/54] docs: improve discoverability of custom schemas documentation (#42634) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Documentation improvement ## What is the current behavior? The documentation for using custom schemas is buried under the REST API section (`/guides/api/using-custom-schemas`), making it hard for users to find when they first encounter schemas in the database documentation. Users who create custom schemas often don't realize they need to configure API access and grant permissions, leading to confusion. Closes #39856 ## What is the new behavior? Three improvements to make custom schemas documentation more discoverable: 1. **Cross-reference in tables page**: Added an admonition tip after the "Schemas" section in `/guides/database/tables` linking to both the "Using Custom Schemas" guide and the "Hardening the Data API" guide 2. **Navigation sidebar**: Added "Using Custom Schemas" link under "Database > Access and security" in the sidebar navigation, so users can find it from the database section without having to navigate to the API section 3. **service_role mention**: Updated the schema grants example in "Hardening the Data API" to include `service_role` alongside `anon` and `authenticated`, since users with server-side access also need this grant ## Additional context The issue author spent hours debugging custom schema access because the documentation wasn't linked from where schemas are first introduced (the database tables page). These changes create a clear path from learning about schemas → configuring API access → security hardening. ## Summary by CodeRabbit * **Documentation** * Added "Using Custom Schemas" guide to the navigation menu under Database -> Access and security section * Enhanced Data API hardening documentation with clarification on service_role permissions for server-side database access * Added instructional tips regarding custom schema exposure via Data API and proper permission configuration --------- Co-authored-by: Chris Chinchilla --- apps/docs/content/guides/database/tables.mdx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/docs/content/guides/database/tables.mdx b/apps/docs/content/guides/database/tables.mdx index f5c20bc080e..96c0570f5dc 100644 --- a/apps/docs/content/guides/database/tables.mdx +++ b/apps/docs/content/guides/database/tables.mdx @@ -452,6 +452,12 @@ create table private.salaries ( ); ``` + + +If you want to access a custom schema through the Supabase Data API, you need to expose it and grant the appropriate permissions. See [Using Custom Schemas](/docs/guides/api/using-custom-schemas) for detailed steps. For security best practices around schema exposure, see [Securing your API](/docs/guides/api/securing-your-api). + + + ## Views A View is a convenient shortcut to a query. Creating a view does not involve new tables or data. When run, an underlying query is executed, returning its results to the user. From 9cf6ae1f6779efcef70dcc94d64e5d8e1cee8304 Mon Sep 17 00:00:00 2001 From: Kalleby Santos <105971119+kallebysantos@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:32:12 +0100 Subject: [PATCH 26/54] feat(self-host): functions expose jwks (#45635) --- docker/docker-compose.yml | 4 +++ docker/utils/add-new-auth-keys.sh | 5 +++- docker/volumes/functions/main/index.ts | 34 ++++++++++++++------------ 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index f8bebc1ff00..80ab7c50761 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -456,6 +456,10 @@ services: environment: # Legacy symmetric HS256 key JWT_SECRET: ${JWT_SECRET} + # JWKS for token verification (EC public + legacy symmetric). + # For Podman, use: SUPABASE_JWKS: ${JWT_JWKS} + #SUPABASE_JWKS: ${JWT_JWKS:-{"keys":[]}} + SUPABASE_URL: http://kong:8000 SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL} # Legacy API keys (HS256-signed JWTs) diff --git a/docker/utils/add-new-auth-keys.sh b/docker/utils/add-new-auth-keys.sh index b728237b7c0..3073ca76365 100644 --- a/docker/utils/add-new-auth-keys.sh +++ b/docker/utils/add-new-auth-keys.sh @@ -168,6 +168,7 @@ echo "" echo " Auth: GOTRUE_JWT_KEYS: \${JWT_KEYS:-[]}" echo " Realtime: API_JWT_JWKS: \${JWT_JWKS:-{\"keys\":[]}}" echo " Storage: JWT_JWKS: \${JWT_JWKS:-{\"keys\":[]}}" +echo " Functions: SUPABASE_JWKS: \${JWT_JWKS:-{\"keys\":[]}}" echo "" if [ "$1" = "--update-env" ]; then @@ -212,11 +213,13 @@ sed -i.old \ -e '/^[ ]*#GOTRUE_JWT_KEYS:/ s/#//' \ -e '/^[ ]*#API_JWT_JWKS:/ s/#//' \ -e '/^[ ]*#JWT_JWKS:/ s/#//' \ + -e '/^[ ]*#SUPABASE_JWKS:/ s/#//' \ docker-compose.yml || true if grep -q '^[ ]*GOTRUE_JWT_KEYS:' docker-compose.yml && \ grep -q '^[ ]*API_JWT_JWKS:' docker-compose.yml && \ - grep -q '^[ ]*JWT_JWKS:' docker-compose.yml; then + grep -q '^[ ]*JWT_JWKS:' docker-compose.yml && \ + grep -q '^[ ]*SUPABASE_JWKS:' docker-compose.yml; then echo "Done." else echo "Warning: could not edit docker-compose.yml. Uncomment auth configuration manually." diff --git a/docker/volumes/functions/main/index.ts b/docker/volumes/functions/main/index.ts index ebe2061c4ba..4761a276657 100644 --- a/docker/volumes/functions/main/index.ts +++ b/docker/volumes/functions/main/index.ts @@ -1,29 +1,32 @@ -import * as jose from 'https://deno.land/x/jose@v4.14.4/index.ts' +import * as jose from 'jsr:@panva/jose@6' console.log('main function started') const JWT_SECRET = Deno.env.get('JWT_SECRET') -const SUPABASE_URL = Deno.env.get('SUPABASE_URL') +const SUPABASE_JWKS = parseJwks(Deno.env.get('SUPABASE_JWKS')) const VERIFY_JWT = Deno.env.get('VERIFY_JWT') === 'true' -// Create JWKS for ES256/RS256 tokens (newer tokens) -let SUPABASE_JWT_KEYS: ReturnType | null = null -if (SUPABASE_URL) { +// NOTE:(kallebysantos) We don't check for valid keys but just the bare array parsing, +// let this for 'jose' lib verification +export function parseJwks(raw: string | undefined): jose.JSONWebKeySet | null { + if (!raw) return null try { - SUPABASE_JWT_KEYS = jose.createRemoteJWKSet( - new URL('/auth/v1/.well-known/jwks.json', SUPABASE_URL) - ) - } catch (e) { - console.error('Failed to fetch JWKS from SUPABASE_URL:', e) + const parsed = JSON.parse(raw) + if (parsed?.keys && Array.isArray(parsed.keys)) { + return parsed as jose.JSONWebKeySet + } + return null + } catch { + return null } } /** * Extract JWT token from Authorization header - * + * * Parses the Authorization header to extract the Bearer token. * Expects format: "Bearer " - * + * * @param req - The HTTP request object * @returns The JWT token string * @throws Error if Authorization header is missing or malformed @@ -47,7 +50,7 @@ async function isValidLegacyJWT(jwt: string): Promise { } const encoder = new TextEncoder(); - const secretKey = encoder.encode(JWT_SECRET) + const secretKey = encoder.encode(JWT_SECRET); try { await jose.jwtVerify(jwt, secretKey); @@ -59,13 +62,14 @@ async function isValidLegacyJWT(jwt: string): Promise { } async function isValidJWT(jwt: string): Promise { - if (!SUPABASE_JWT_KEYS) { + if (!SUPABASE_JWKS) { console.error('JWKS not available for ES256/RS256 token verification') return false } try { - await jose.jwtVerify(jwt, SUPABASE_JWT_KEYS) + const localJwks = jose.createLocalJWKSet(SUPABASE_JWKS); + await jose.jwtVerify(jwt, localJwks); } catch (e) { console.error('Asymmetric JWT verification error', e); return false From 5066261dbd4fccf0c3cf0503673e70429825ed22 Mon Sep 17 00:00:00 2001 From: Jeremias Menichelli Date: Wed, 8 Jul 2026 16:00:47 +0200 Subject: [PATCH 27/54] [DOCS-1148] Improve prose for LLM readability (#47653) --- .../docs/content/_partials/metrics_access.mdx | 2 +- apps/docs/content/guides/ai/concepts.mdx | 2 +- apps/docs/content/guides/ai/hugging-face.mdx | 2 +- apps/docs/content/guides/auth/auth-mfa.mdx | 2 +- .../auth/server-side/creating-a-client.mdx | 4 ++-- .../local-development/cli/getting-started.mdx | 6 +++--- .../platform/migrating-to-supabase/auth0.mdx | 14 ++++++++----- .../analytics/creating-analytics-buckets.mdx | 7 ++++++- .../guides/storage/s3/authentication.mdx | 7 ++++++- .../docs/content/guides/telemetry/metrics.mdx | 5 +++-- .../telemetry/metrics/grafana-cloud.mdx | 5 +++-- .../telemetry/metrics/grafana-self-hosted.mdx | 5 +++-- .../telemetry/metrics/vendor-agnostic.mdx | 5 +++-- .../internals/generate-guides-markdown.ts | 7 +++++++ .../internals/markdown-schema/Accordion.ts | 10 +++++++++ apps/docs/internals/markdown-schema/Icons.ts | 2 ++ apps/docs/internals/markdown-schema/Image.ts | 21 +++++++++++++++++++ apps/docs/internals/markdown-schema/Panel.ts | 15 +++++++++++-- 18 files changed, 95 insertions(+), 26 deletions(-) create mode 100644 apps/docs/internals/markdown-schema/Accordion.ts create mode 100644 apps/docs/internals/markdown-schema/Icons.ts create mode 100644 apps/docs/internals/markdown-schema/Image.ts diff --git a/apps/docs/content/_partials/metrics_access.mdx b/apps/docs/content/_partials/metrics_access.mdx index 94a3389207c..4f65008ed02 100644 --- a/apps/docs/content/_partials/metrics_access.mdx +++ b/apps/docs/content/_partials/metrics_access.mdx @@ -7,7 +7,7 @@ className="rounded-lg border border-foreground/10 bg-surface-100 text-foreground > What you can do with the Metrics API} + header="What you can do with the Metrics API" id="how-do-i-check-when-a-user-went-through-mfa" className="border-0 px-2 py-4" > diff --git a/apps/docs/content/guides/ai/concepts.mdx b/apps/docs/content/guides/ai/concepts.mdx index 357581cc455..73f52fc93d6 100644 --- a/apps/docs/content/guides/ai/concepts.mdx +++ b/apps/docs/content/guides/ai/concepts.mdx @@ -41,7 +41,7 @@ Embeddings compress discrete information (words & symbols) into distributed cont The chart below plots example phrases as points. Phrases with similar meanings sit close together, and unrelated phrases sit far apart. -A two-dimensional chart plotting example phrases as points, where phrases with similar meanings sit close together and unrelated phrases sit far apart. How do I check when a user went through MFA?} +header="How do I check when a user went through MFA?" id="how-do-i-check-when-a-user-went-through-mfa" > diff --git a/apps/docs/content/guides/auth/server-side/creating-a-client.mdx b/apps/docs/content/guides/auth/server-side/creating-a-client.mdx index 3dfe963a7e5..41dfa60552b 100644 --- a/apps/docs/content/guides/auth/server-side/creating-a-client.mdx +++ b/apps/docs/content/guides/auth/server-side/creating-a-client.mdx @@ -200,7 +200,7 @@ The Proxy is responsible for: What does the `cookies` object do?} + header="What does the `cookies` object do?" id="utility-cookies" > @@ -213,7 +213,7 @@ The Proxy is responsible for: Do I need to create a new client for every route?} + header="Do I need to create a new client for every route?" id="client-deduplication" > diff --git a/apps/docs/content/guides/local-development/cli/getting-started.mdx b/apps/docs/content/guides/local-development/cli/getting-started.mdx index c6005edb3c6..10838693b14 100644 --- a/apps/docs/content/guides/local-development/cli/getting-started.mdx +++ b/apps/docs/content/guides/local-development/cli/getting-started.mdx @@ -58,7 +58,7 @@ and run one of the following: - `sudo rpm -i <...>.rpm` - + Run the CLI by prefixing each command with `npx` or `bunx`: @@ -122,7 +122,7 @@ brew link --overwrite supabase-beta Beta builds are attached to [GitHub pre-releases](https://github.com/supabase/cli/releases). Download the `.apk`, `.deb`, or `.rpm` for your platform and install with the same commands as [Linux packages](#linux-packages) above. - + Install as a dev dependency: @@ -199,7 +199,7 @@ brew upgrade supabase-beta - `sudo rpm -i <...>.rpm` - + If you have installed the CLI as dev dependency via [npm](https://www.npmjs.com/package/supabase), you can update it with: diff --git a/apps/docs/content/guides/platform/migrating-to-supabase/auth0.mdx b/apps/docs/content/guides/platform/migrating-to-supabase/auth0.mdx index 82f8d91462b..806660c54a5 100644 --- a/apps/docs/content/guides/platform/migrating-to-supabase/auth0.mdx +++ b/apps/docs/content/guides/platform/migrating-to-supabase/auth0.mdx @@ -80,6 +80,7 @@ Migrate existing users to Supabase Auth. This requires two main steps: first, ch ```ts import { createClient } from '@supabase/supabase-js' + const supabase = createClient('your_project_url', 'your_supabase_api_key') // ---cut--- @@ -100,6 +101,7 @@ Migrate existing users to Supabase Auth. This requires two main steps: first, ch ```ts import { createClient } from '@supabase/supabase-js' + const supabase = createClient('your_project_url', 'your_supabase_api_key') // ---cut--- @@ -122,6 +124,7 @@ For passwordless signin via email or phone, check for users with verified email ```ts import { createClient } from '@supabase/supabase-js' + const supabase = createClient('your_project_url', 'your_supabase_api_key') // ---cut--- @@ -162,6 +165,7 @@ Both columns are accessible from the admin user methods. To create a user with c ```ts import { createClient } from '@supabase/supabase-js' + const supabase = createClient('your_project_url', 'your_supabase_api_key') // ---cut--- @@ -205,7 +209,7 @@ create table private.user_metadata ( > I have IDs assigned to existing users in my database, how can I maintain these IDs?} +header="I have IDs assigned to existing users in my database, how can I maintain these IDs?" id="custom-user-id" > @@ -228,7 +232,7 @@ const { data, error } = await supabase.auth.admin.createUser({ How can I allow my users to retain their existing password?} +header="How can I allow my users to retain their existing password?" id="existing-password" > @@ -237,7 +241,7 @@ Supabase Auth never stores passwords as plaintext. Since Supabase Auth supports My users have multi-factor authentication (MFA) enabled, how do I make sure they don't have to set up MFA again?} +header="My users have multi-factor authentication (MFA) enabled, how do I make sure they don't have to set up MFA again?" id="mfa" > @@ -246,7 +250,7 @@ You can obtain an export of your users' MFA secrets by opening a support ticket How do I migrate existing SAML Single Sign-On (SSO) connections?} +header="How do I migrate existing SAML Single Sign-On (SSO) connections?" id="saml" > @@ -255,7 +259,7 @@ Customers may need to link their identity provider with Supabase Auth separately How do I migrate my Auth0 organizations to Supabase?} +header="How do I migrate my Auth0 organizations to Supabase?" id="migrate-org" > diff --git a/apps/docs/content/guides/storage/analytics/creating-analytics-buckets.mdx b/apps/docs/content/guides/storage/analytics/creating-analytics-buckets.mdx index f9a6c7b1fe2..2680ec95879 100644 --- a/apps/docs/content/guides/storage/analytics/creating-analytics-buckets.mdx +++ b/apps/docs/content/guides/storage/analytics/creating-analytics-buckets.mdx @@ -72,7 +72,12 @@ print('Analytics bucket created:', response) 4. Select **Analytics Bucket** as the bucket type. 5. Click **Create**. -Create Analytics Bucket in Dashboard +Create Analytics Bucket in Dashboard ## Next steps diff --git a/apps/docs/content/guides/storage/s3/authentication.mdx b/apps/docs/content/guides/storage/s3/authentication.mdx index 879f3f11149..294e0b24c53 100644 --- a/apps/docs/content/guides/storage/s3/authentication.mdx +++ b/apps/docs/content/guides/storage/s3/authentication.mdx @@ -23,7 +23,12 @@ To authenticate with S3, generate a pair of credentials (Access Key ID and Secre This is all the information you need to connect to Supabase Storage using any S3-compatible service. -Storage S3 Access keys +Storage S3 Access keys diff --git a/apps/docs/content/guides/telemetry/metrics.mdx b/apps/docs/content/guides/telemetry/metrics.mdx index 4fd0dc73531..5ecbf90e80b 100644 --- a/apps/docs/content/guides/telemetry/metrics.mdx +++ b/apps/docs/content/guides/telemetry/metrics.mdx @@ -27,10 +27,11 @@ Pick the workflow that best matches your tooling. Cards link to Supabase-authore -Supabase Grafana dashboard showcasing database metrics ## Additional resources diff --git a/apps/docs/content/guides/telemetry/metrics/grafana-cloud.mdx b/apps/docs/content/guides/telemetry/metrics/grafana-cloud.mdx index a31a8e4b06e..39d96d066ba 100644 --- a/apps/docs/content/guides/telemetry/metrics/grafana-cloud.mdx +++ b/apps/docs/content/guides/telemetry/metrics/grafana-cloud.mdx @@ -44,10 +44,11 @@ If you prefer to reuse an existing Grafana Agent deployment, configure an [integ This dashboard includes 200+ charts grouped by CPU, IO, connections, replication, WAL, and bloat indicators. -Supabase Grafana dashboard showcasing database metrics ## 4. Configure alerts (optional) diff --git a/apps/docs/content/guides/telemetry/metrics/grafana-self-hosted.mdx b/apps/docs/content/guides/telemetry/metrics/grafana-self-hosted.mdx index 5e10dbd38a0..917ecfbc9e8 100644 --- a/apps/docs/content/guides/telemetry/metrics/grafana-self-hosted.mdx +++ b/apps/docs/content/guides/telemetry/metrics/grafana-self-hosted.mdx @@ -63,10 +63,11 @@ Install Grafana (Docker image, Helm chart, or packages) and connect it to Promet You now have over 200 production-ready panels covering CPU, IO, WAL, replication, index bloat, and query throughput. -Supabase Grafana dashboard showcasing database metrics ## 4. Configure alerting diff --git a/apps/docs/content/guides/telemetry/metrics/vendor-agnostic.mdx b/apps/docs/content/guides/telemetry/metrics/vendor-agnostic.mdx index 69d1291e8ec..12cb2746b9d 100644 --- a/apps/docs/content/guides/telemetry/metrics/vendor-agnostic.mdx +++ b/apps/docs/content/guides/telemetry/metrics/vendor-agnostic.mdx @@ -51,10 +51,11 @@ No matter which collector you use, you need to hit the Metrics API once per minu - For other tools, group metrics by categories (CPU, IO, WAL, replication, connections) and recreate the visualizations that matter most to your team. - Tag or relabel series with `project`, `env`, or `team` labels to make multi-project views easier. -Supabase Grafana dashboard showcasing database metrics ## 4. Alerts and automation diff --git a/apps/docs/internals/generate-guides-markdown.ts b/apps/docs/internals/generate-guides-markdown.ts index be6a75bf2b6..5bdca3e1c73 100644 --- a/apps/docs/internals/generate-guides-markdown.ts +++ b/apps/docs/internals/generate-guides-markdown.ts @@ -13,12 +13,15 @@ import { parse as parseToml } from 'smol-toml' import { mcpConfigPanelMarkdown as McpConfigPanel } from 'ui-patterns/McpUrlBuilder/McpConfigPanel.md' import { addBaseUrlPrefix } from './internal-links' +import { AccordionItem } from './markdown-schema/Accordion' import { Admonition } from './markdown-schema/Admonition' import { AuthProviders } from './markdown-schema/AuthProviders' import { ComputeDiskLimitsTable } from './markdown-schema/ComputeDiskLimitsTable' import { ContentListings } from './markdown-schema/ContentListings' import { CustomContent } from './markdown-schema/CustomContent' import { ErrorCodes } from './markdown-schema/ErrorCodes' +import { IconCheck, IconX } from './markdown-schema/Icons' +import { Image } from './markdown-schema/Image' import { Link } from './markdown-schema/Link' import { McpCiConfigBlock } from './markdown-schema/McpCiConfigBlock' import { MetricsStackCards } from './markdown-schema/MetricsStackCards' @@ -151,7 +154,11 @@ function applySchema(parent: Parent, schema: ComponentSchema): void { * component not listed is unwrapped (children are kept, wrapper is dropped). */ const SCHEMA: ComponentSchema = { + AccordionItem, Admonition, + IconCheck, + IconX, + Image, AuthProviders, ComputeDiskLimitsTable, CustomContent, diff --git a/apps/docs/internals/markdown-schema/Accordion.ts b/apps/docs/internals/markdown-schema/Accordion.ts new file mode 100644 index 00000000000..6daa2be7f84 --- /dev/null +++ b/apps/docs/internals/markdown-schema/Accordion.ts @@ -0,0 +1,10 @@ +export const AccordionItem = ({ + props, + children, +}: { + props: Record + children: string +}): string => { + const header = String(props.header ?? '').trim() + return header ? `**${header}**\n\n${children}` : children +} diff --git a/apps/docs/internals/markdown-schema/Icons.ts b/apps/docs/internals/markdown-schema/Icons.ts new file mode 100644 index 00000000000..fe77b3f0a6e --- /dev/null +++ b/apps/docs/internals/markdown-schema/Icons.ts @@ -0,0 +1,2 @@ +export const IconCheck = (): string => '✅' +export const IconX = (): string => '❌' diff --git a/apps/docs/internals/markdown-schema/Image.ts b/apps/docs/internals/markdown-schema/Image.ts new file mode 100644 index 00000000000..07c65b8281f --- /dev/null +++ b/apps/docs/internals/markdown-schema/Image.ts @@ -0,0 +1,21 @@ +import { getInternalLinkBaseUrl, withDocsBasePath } from '../internal-links' + +export const Image = ({ props }: { props: Record }): string => { + const alt = String(props.alt ?? '') + const rawSrc = props.src as string + + // propsFrom() always stores attribute values as strings: either the literal + // string value for `src="/..."`, or the raw JS expression for `src={{ dark, light }}`. + const srcStr = String(rawSrc ?? '') + let src = String(props.src) + + if (!srcStr.startsWith('/')) { + // Object expression form: `src={{ dark: '/...', light: '/...' }}` + const match = + srcStr.match(/dark:\s*['"]([^'"]+)['"]/) ?? srcStr.match(/light:\s*['"]([^'"]+)['"]/) + src = match ? match[1] : '' + } + + const baseUrl = getInternalLinkBaseUrl() + return `![${alt}](${baseUrl}${withDocsBasePath(src)})` +} diff --git a/apps/docs/internals/markdown-schema/Panel.ts b/apps/docs/internals/markdown-schema/Panel.ts index 2962e24d403..30012844614 100644 --- a/apps/docs/internals/markdown-schema/Panel.ts +++ b/apps/docs/internals/markdown-schema/Panel.ts @@ -1,3 +1,14 @@ -export const Panel = ({ props }: { props: Record }): string => { - return String(props.title) +export const Panel = ({ + props, + children, +}: { + props: Record + children: string +}): string => { + const title = String(props.title ?? '') + const description = children.replace(/\s+/g, ' ').trim() + + if (title && description) return `${title}: ${description}` + + return title || description } From c070893475be7a2ecaa3f7104e5c348916a55cdd Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Wed, 8 Jul 2026 19:41:39 +0530 Subject: [PATCH 28/54] fix: limit regex (#47717) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - closes https://github.com/supabase/supabase/issues/47712 ## Summary by CodeRabbit * **Bug Fixes** * Improved SQL query handling so automatic row limits are no longer added when a query already ends with `LIMIT`, even if there’s whitespace before the semicolon. * Preserved correct behavior for queries using `LIMIT ... OFFSET ...`. * **Tests** * Expanded coverage for SQL limit detection and limit-suffix behavior around whitespace and semicolon placement. --- .../SQLEditor/SQLEditor.utils.test.ts | 18 ++++++++++++++++++ .../interfaces/SQLEditor/SQLEditor.utils.ts | 4 ++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts index 799b6562075..51b01338b15 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts @@ -53,12 +53,24 @@ describe('SQLEditor.utils.ts:checkIfAppendLimitRequired', () => { const { appendAutoLimit } = checkIfAppendLimitRequired(sql, limit) expect(appendAutoLimit).toBe(false) }) + test('Should return false if query already has a limit with whitespace before the semi colon', () => { + const sql = 'select * from countries limit 10 ;' + const limit = 100 + const { appendAutoLimit } = checkIfAppendLimitRequired(sql, limit) + expect(appendAutoLimit).toBe(false) + }) test('Should return false if query already has a limit and offset', () => { const sql = 'select * from countries limit 10 offset 0;' const limit = 100 const { appendAutoLimit } = checkIfAppendLimitRequired(sql, limit) expect(appendAutoLimit).toBe(false) }) + test('Should return false if query already has a limit and offset with whitespace before the semi colon', () => { + const sql = 'select * from countries limit 10 offset 0 ;' + const limit = 100 + const { appendAutoLimit } = checkIfAppendLimitRequired(sql, limit) + expect(appendAutoLimit).toBe(false) + }) test('Should return false if query already has a limit and offset (flip order of limit and offset)', () => { const sql = 'select * from countries offset 0 limit 1;' const limit = 100 @@ -154,6 +166,12 @@ describe('SQLEditor.utils.ts:suffixWithLimit', () => { const formattedSql = suffixWithLimit(sql, limit) expect(formattedSql).toBe('select * from countries limit 100;') }) + test('Should not append a limit if query already has one with whitespace before the semi colon', () => { + const sql = safeSql`select * from countries limit 10 ;` + const limit = 100 + const formattedSql = suffixWithLimit(sql, limit) + expect(formattedSql).toBe('select * from countries limit 10 ;') + }) }) describe(`SQLEditor.utils.ts:checkDestructiveQuery`, () => { diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts index 9e01e8c24a2..d662302048d 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts @@ -249,8 +249,8 @@ export const checkIfAppendLimitRequired = (sql: string, limit: number = 0) => { !cleanedSql.toLowerCase().match(/fetch\s+first/i) && !cleanedSql.match(/limit$/i) && !cleanedSql.match(/limit;$/i) && - !cleanedSql.match(/limit [0-9]* offset [0-9]*[;]?$/i) && - !cleanedSql.match(/limit [0-9]*[;]?$/i) + !cleanedSql.match(/limit [0-9]* offset [0-9]*\s*[;]?$/i) && + !cleanedSql.match(/limit [0-9]*\s*[;]?$/i) return { cleanedSql, appendAutoLimit } } From 982d86012340eaf8525576f9965bb66e650dce08 Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:16:08 +0200 Subject: [PATCH 29/54] feat(functions): migrate last-hour stats query to OTEL ClickHouse (#47693) ## What Migrates the `edge-functions-last-hour-stats` query (requests + server error counts shown on the Edge Functions list) from the BigQuery-style `logs.all` endpoint to the OTEL/ClickHouse `logs.all.otel` endpoint. CleanShot 2026-07-07 at 16 24 43@2x ## How - Adds an OTEL SQL builder that reads from the single `logs` table (`source = 'function_edge_logs'`), using `log_attributes['function_id']` and `toInt32OrZero(log_attributes['response.status_code'])` instead of `cross join unnest(metadata)`. - Gated by the `otelLegacyLogs` flag, matching the rest of the logs code. The BigQuery path is preserved when the flag is off, and the two paths cache under separate query keys. ## Testing - Unit tests cover both endpoints and assert the generated SQL for each path. - Go to edge fns list - stats load correctly ## Summary by CodeRabbit * **New Features** * Added support for using the OTEL logs backend for edge function last-hour stats when enabled. * Queries and caching now automatically distinguish between the standard and OTEL-backed data sources. * **Bug Fixes** * Ensured stats results are fetched from the correct endpoint based on the selected logging backend. * Added coverage to verify OTEL-specific SQL and response behavior. --- ...ge-functions-last-hour-stats-query.test.ts | 38 +++++++++++- .../edge-functions-last-hour-stats-query.ts | 58 ++++++++++++++++--- apps/studio/data/edge-functions/keys.ts | 7 ++- 3 files changed, 92 insertions(+), 11 deletions(-) diff --git a/apps/studio/data/edge-functions/edge-functions-last-hour-stats-query.test.ts b/apps/studio/data/edge-functions/edge-functions-last-hour-stats-query.test.ts index 9ab03297e99..c94fc2c62f9 100644 --- a/apps/studio/data/edge-functions/edge-functions-last-hour-stats-query.test.ts +++ b/apps/studio/data/edge-functions/edge-functions-last-hour-stats-query.test.ts @@ -21,7 +21,7 @@ describe('getEdgeFunctionsLastHourStats', () => { vi.useRealTimers() }) - it('requests last-hour function stats from logs.all', async () => { + it('requests last-hour function stats from logs.all by default', async () => { vi.mocked(post).mockResolvedValue({ data: { result: [] }, error: null } as PostResponse) await getEdgeFunctionsLastHourStats({ @@ -49,6 +49,42 @@ describe('getEdgeFunctionsLastHourStats', () => { expect(postCalls[0]?.[1]?.body?.sql).toContain('from\n function_edge_logs') }) + it('requests last-hour function stats from logs.all.otel when useOtel is set', async () => { + vi.mocked(post).mockResolvedValue({ data: { result: [] }, error: null } as PostResponse) + + await getEdgeFunctionsLastHourStats({ + projectRef: 'project-ref', + functionIds: ['fn_1', 'fn_2'], + useOtel: true, + }) + + expect(post).toHaveBeenCalledWith( + '/platform/projects/{ref}/analytics/endpoints/logs.all.otel', + { + params: { + path: { ref: 'project-ref' }, + query: { key: 'last-hour-stats' }, + }, + body: expect.objectContaining({ + sql: expect.stringContaining(`and log_attributes['function_id'] in ('fn_1', 'fn_2')`), + iso_timestamp_start: '2024-01-15T11:00:00.000Z', + iso_timestamp_end: '2024-01-15T12:00:00.000Z', + }), + signal: undefined, + } + ) + + const postCalls = vi.mocked(post).mock.calls as Array< + [string, { body?: { sql?: string } } | undefined] + > + const sql = postCalls[0]?.[1]?.body?.sql ?? '' + + expect(sql).toContain('from logs') + expect(sql).toContain("source = 'function_edge_logs'") + expect(sql).toContain("case when toInt32OrZero(log_attributes['response.status_code']) >= 500") + expect(sql).not.toContain('cross join unnest') + }) + it('coerces counts to numbers and computes error rates per function', async () => { vi.mocked(post).mockResolvedValue({ data: { diff --git a/apps/studio/data/edge-functions/edge-functions-last-hour-stats-query.ts b/apps/studio/data/edge-functions/edge-functions-last-hour-stats-query.ts index a1b6305bb7d..9f372c66901 100644 --- a/apps/studio/data/edge-functions/edge-functions-last-hour-stats-query.ts +++ b/apps/studio/data/edge-functions/edge-functions-last-hour-stats-query.ts @@ -1,9 +1,11 @@ import { useQuery } from '@tanstack/react-query' +import { useFlag } from 'common' import dayjs from 'dayjs' import { edgeFunctionsKeys } from './keys' import { handleError } from '@/data/fetchers' import { executeAnalyticsSql } from '@/data/logs/execute-analytics-sql' +import { logsAllEndpointUrl } from '@/data/logs/logs-endpoint' import { analyticsLiteral, joinSqlFragments, @@ -12,7 +14,11 @@ import { } from '@/data/logs/safe-analytics-sql' import type { ResponseError, UseCustomQueryOptions } from '@/types' -export type EdgeFunctionsLastHourStatsVariables = { projectRef?: string; functionIds?: string[] } +export type EdgeFunctionsLastHourStatsVariables = { + projectRef?: string + functionIds?: string[] + useOtel?: boolean +} export type EdgeFunctionLastHourStats = { functionId: string @@ -23,7 +29,7 @@ export type EdgeFunctionLastHourStats = { export type EdgeFunctionsLastHourStatsResponse = Record -function getEdgeFunctionsLastHourStatsSql(functionIds: string[]): SafeLogSqlFragment { +function getEdgeFunctionsLastHourStatsSqlBq(functionIds: string[]): SafeLogSqlFragment { const functionIdFilter: SafeLogSqlFragment = functionIds.length > 0 ? safeSql` and function_id in (${joinSqlFragments(functionIds.map(analyticsLiteral), ', ')})\n` @@ -46,8 +52,38 @@ ${functionIdFilter}group by ` } +function getEdgeFunctionsLastHourStatsSqlOtel(functionIds: string[]): SafeLogSqlFragment { + const functionIdFilter: SafeLogSqlFragment = + functionIds.length > 0 + ? safeSql` and log_attributes['function_id'] in (${joinSqlFragments(functionIds.map(analyticsLiteral), ', ')})\n` + : safeSql`` + + return safeSql` +-- edge-functions-last-hour-stats +select + log_attributes['function_id'] as function_id, + count(distinct id) as requests_count, + count(distinct case when toInt32OrZero(log_attributes['response.status_code']) >= 500 then id end) as server_err_count +from logs +where + source = 'function_edge_logs' + and log_attributes['function_id'] != '' +${functionIdFilter}group by + function_id +` +} + +function getEdgeFunctionsLastHourStatsSql( + functionIds: string[], + useOtel: boolean +): SafeLogSqlFragment { + return useOtel + ? getEdgeFunctionsLastHourStatsSqlOtel(functionIds) + : getEdgeFunctionsLastHourStatsSqlBq(functionIds) +} + export async function getEdgeFunctionsLastHourStats( - { projectRef, functionIds = [] }: EdgeFunctionsLastHourStatsVariables, + { projectRef, functionIds = [], useOtel = false }: EdgeFunctionsLastHourStatsVariables, signal?: AbortSignal ) { if (!projectRef) throw new Error('projectRef is required') @@ -58,8 +94,8 @@ export async function getEdgeFunctionsLastHourStats( const data = await executeAnalyticsSql({ projectRef, - endpoint: '/platform/projects/{ref}/analytics/endpoints/logs.all', - sql: getEdgeFunctionsLastHourStatsSql(functionIds), + endpoint: logsAllEndpointUrl(useOtel), + sql: getEdgeFunctionsLastHourStatsSql(functionIds, useOtel), iso_timestamp_start: startDate, iso_timestamp_end: endDate, key: 'last-hour-stats', @@ -108,12 +144,16 @@ export const useEdgeFunctionsLastHourStatsQuery = = {} -) => - useQuery({ - queryKey: edgeFunctionsKeys.lastHourStats(projectRef, functionIds), - queryFn: ({ signal }) => getEdgeFunctionsLastHourStats({ projectRef, functionIds }, signal), +) => { + const useOtel = useFlag('otelLegacyLogs') + + return useQuery({ + queryKey: edgeFunctionsKeys.lastHourStats(projectRef, functionIds, useOtel), + queryFn: ({ signal }) => + getEdgeFunctionsLastHourStats({ projectRef, functionIds, useOtel }, signal), enabled: enabled && typeof projectRef !== 'undefined' && functionIds.length > 0, staleTime: 60 * 1000, retry: false, ...options, }) +} diff --git a/apps/studio/data/edge-functions/keys.ts b/apps/studio/data/edge-functions/keys.ts index 1b13ea0eba7..11aaa18993e 100644 --- a/apps/studio/data/edge-functions/keys.ts +++ b/apps/studio/data/edge-functions/keys.ts @@ -3,13 +3,18 @@ export const normalizeFunctionIds = (functionIds: string[]): string[] => export const edgeFunctionsKeys = { list: (projectRef: string | undefined) => ['projects', projectRef, 'edge-functions'] as const, - lastHourStats: (projectRef: string | undefined, functionIds: string[] = []) => + lastHourStats: ( + projectRef: string | undefined, + functionIds: string[] = [], + useOtel: boolean = false + ) => [ 'projects', projectRef, 'edge-functions', 'last-hour-stats', normalizeFunctionIds(functionIds), + { otel: useOtel }, ] as const, detail: (projectRef: string | undefined, slug: string | undefined) => ['projects', projectRef, 'edge-function', slug, 'detail'] as const, From 944c5862f3fa22daaf904e31da41e55b509c81f3 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 8 Jul 2026 23:21:48 +0800 Subject: [PATCH 30/54] Chore/small refactors (#47740) ## Context Just extracting the fixes which I think are applicable from this [PR](https://github.com/supabase/supabase/pull/47695) Main files are - `apps/studio/hooks/analytics/useLogsQuery.tsx` - `packages/common/auth.tsx` - `packages/common/feature-flags.tsx` ## Changes involved - Adjust `useLogsQuery` to accept an object as prop, rather than 4 individual params - This one doesn't address any Sentry issues, but is just a improvement to the function's API imo, more readable - Adjust how user email is retrieved in `feature-flags` - Related Sentry issue [here](https://supabase.sentry.io/issues/7592718607/?project=5459134) - The error is a bit vague, but Claude's attempt to fix looks alright in general IMO - Minimally verified that feature flags are loading as expected still ## Summary by CodeRabbit * **Bug Fixes** * Improved log-related screens and queries for more reliable loading and filtering across the app. * Fixed profile and account data handling so identity details are retrieved more consistently. * Improved authentication handling to better recognize missing user data and keep the app stable. * Updated feature flag personalization to use more accurate account information. --- .../interfaces/APIKeys/SecretAPIKeys.tsx | 8 ++--- .../EdgeFunctionRecentErrors.tsx | 36 +++++++++---------- .../QueryInsights/QueryInsights.tsx | 11 +++--- .../interfaces/Reports/Reports.utils.tsx | 2 +- .../DisplayApiSettings.utils.ts | 9 ++--- .../data/profile/profile-identities-query.ts | 13 ++++--- apps/studio/hooks/analytics/useLogsQuery.tsx | 17 +++++---- .../project/[ref]/logs/explorer/index.tsx | 31 ++++++++-------- packages/common/auth.tsx | 11 +++++- packages/common/feature-flags.tsx | 11 +++--- 10 files changed, 82 insertions(+), 67 deletions(-) diff --git a/apps/studio/components/interfaces/APIKeys/SecretAPIKeys.tsx b/apps/studio/components/interfaces/APIKeys/SecretAPIKeys.tsx index aca9bef5988..e359a35113f 100644 --- a/apps/studio/components/interfaces/APIKeys/SecretAPIKeys.tsx +++ b/apps/studio/components/interfaces/APIKeys/SecretAPIKeys.tsx @@ -35,15 +35,15 @@ function useLastSeen({ projectRef, enabled }: { projectRef: string; enabled?: bo } { const now = useRef(new Date()).current - const query = useLogsQuery( + const query = useLogsQuery({ + enabled, projectRef, - { + initialParams: { iso_timestamp_start: new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(), iso_timestamp_end: now.toISOString(), sql: "-- last-used-secret-api-keys\nSELECT unix_millis(max(timestamp)) as timestamp, apikey.`hash` FROM edge_logs cross join unnest(metadata) as m cross join unnest(m.request) as request cross join unnest(request.sb) as sb cross join unnest(sb.apikey) as sbapikey cross join unnest(sbapikey.apikey) as apikey WHERE apikey.error is null and apikey.`hash` is not null and apikey.prefix like 'sb_secret_%' GROUP BY apikey.`hash`", }, - enabled - ) + }) return useMemo(() => { if (query.isLoading || !query.logData) { diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.tsx index c58c800761e..8333ec6445a 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.tsx @@ -87,16 +87,16 @@ export const EdgeFunctionRecentErrors = ({ logData: recentErrorInvocations, isLoading: isLoadingRecentErrorInvocations, error: recentErrorInvocationsError, - } = useLogsQuery( - projectRef as string, - { + } = useLogsQuery({ + projectRef: projectRef!, + initialParams: { sql: recentErrorInvocationsSql, iso_timestamp_start: isoTimestampStart, iso_timestamp_end: isoTimestampEnd, }, - isQueryEnabled, - { useOtel: true } - ) + enabled: isQueryEnabled, + options: { useOtel: true }, + }) const recentErrorGroupsBase = useMemo( () => getRecentErrorGroupsBase(recentErrorInvocations), @@ -106,16 +106,16 @@ export const EdgeFunctionRecentErrors = ({ logData: sinceLastDeployInvocationCountRows, isLoading: isLoadingSinceLastDeployInvocationCount, error: sinceLastDeployInvocationCountError, - } = useLogsQuery( - projectRef as string, - { + } = useLogsQuery({ + projectRef: projectRef!, + initialParams: { sql: sinceLastDeployInvocationCountSql, iso_timestamp_start: isoTimestampStart, iso_timestamp_end: isoTimestampEnd, }, - Boolean(projectRef && sinceLastDeployInvocationCountSql && isoTimestampStart), - { useOtel: true } - ) + enabled: Boolean(projectRef && sinceLastDeployInvocationCountSql && isoTimestampStart), + options: { useOtel: true }, + }) const relatedExecutionIds = useMemo( () => getRelatedExecutionIds(recentErrorGroupsBase), @@ -131,16 +131,16 @@ export const EdgeFunctionRecentErrors = ({ logData: functionRuntimeLogs, isLoading: isLoadingFunctionRuntimeLogs, error: functionRuntimeLogsError, - } = useLogsQuery( - projectRef as string, - { + } = useLogsQuery({ + projectRef: projectRef!, + initialParams: { sql: functionRuntimeLogsSql, iso_timestamp_start: isoTimestampStart, iso_timestamp_end: isoTimestampEnd, }, - Boolean(projectRef && functionRuntimeLogsSql && isoTimestampStart), - { useOtel: true } - ) + enabled: Boolean(projectRef && functionRuntimeLogsSql && isoTimestampStart), + options: { useOtel: true }, + }) const queryError = toAlertError(recentErrorInvocationsError) ?? toAlertError(functionRuntimeLogsError) diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsights.tsx b/apps/studio/components/interfaces/QueryInsights/QueryInsights.tsx index 0d022f375cb..4e0802c8745 100644 --- a/apps/studio/components/interfaces/QueryInsights/QueryInsights.tsx +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsights.tsx @@ -53,10 +53,13 @@ export const QueryInsights = ({ dateRange }: QueryInsightsProps) => { [effectiveDateRange] ) - const { logData, isLoading } = useLogsQuery(ref as string, { - sql, - iso_timestamp_start: effectiveDateRange.iso_timestamp_start, - iso_timestamp_end: effectiveDateRange.iso_timestamp_end, + const { logData, isLoading } = useLogsQuery({ + projectRef: ref as string, + initialParams: { + sql, + iso_timestamp_start: effectiveDateRange.iso_timestamp_start, + iso_timestamp_end: effectiveDateRange.iso_timestamp_end, + }, }) const [selectedQuery, setSelectedQuery] = useState(null) diff --git a/apps/studio/components/interfaces/Reports/Reports.utils.tsx b/apps/studio/components/interfaces/Reports/Reports.utils.tsx index f7f58c41a15..e844f6241db 100644 --- a/apps/studio/components/interfaces/Reports/Reports.utils.tsx +++ b/apps/studio/components/interfaces/Reports/Reports.utils.tsx @@ -58,7 +58,7 @@ export const queriesFactory = ( } else { return { ...acc, - [k]: () => useLogsQuery(projectRef), + [k]: () => useLogsQuery({ projectRef }), } } }, {}) diff --git a/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.utils.ts b/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.utils.ts index 1afca9a16dd..6281e3c6361 100644 --- a/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.utils.ts +++ b/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.utils.ts @@ -11,15 +11,16 @@ export function useLastUsedAPIKeysLogQuery({ enabled?: boolean }) { const now = useRef(new Date()).current - return useLogsQuery( + + return useLogsQuery({ projectRef, - { + enabled, + initialParams: { iso_timestamp_start: new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(), iso_timestamp_end: now.toISOString(), sql: "-- last-used-anon--service_role-api-keys\nSELECT unix_millis(max(timestamp)) as timestamp, payload.role, payload.signature_prefix FROM edge_logs cross join unnest(metadata) as m cross join unnest(m.request) as request cross join unnest(request.sb) as sb cross join unnest(sb.jwt) as jwt cross join unnest(jwt.apikey) as apikey cross join unnest(apikey.payload) as payload WHERE apikey.invalid is null and payload.issuer = 'supabase' and payload.algorithm = 'HS256' and payload.role in ('anon', 'service_role') GROUP BY payload.role, payload.signature_prefix", }, - enabled - ) + }) } export function getLastUsedAPIKeys( diff --git a/apps/studio/data/profile/profile-identities-query.ts b/apps/studio/data/profile/profile-identities-query.ts index f41bcd98ca8..cb7260499c1 100644 --- a/apps/studio/data/profile/profile-identities-query.ts +++ b/apps/studio/data/profile/profile-identities-query.ts @@ -6,16 +6,15 @@ import { auth } from '@/lib/gotrue' import { UseCustomQueryOptions } from '@/types' export async function getProfileIdentities() { - // getSession() uses a cached user object, which is almost never stale as the - // session refresh logic keeps it fresh. If there are claims of data not being - // fresh, it's because it was modified on another device / browser and the - // session hasn't been refreshed yet. - const { error, data } = await auth.getSession() + // getUser() is used instead of getSession() here because the client is configured + // with a `userStorage` option, under which getSession() can return a session whose + // `user` is a placeholder that throws when any of its properties are read. + const { error, data } = await auth.getUser() if (error) throw error - if (!data.session) throw new Error('Session not found with getSession()') + if (!data.user) throw new Error('User not found with getUser()') - const { identities = [], new_email, email_change_sent_at } = data.session.user + const { identities = [], new_email, email_change_sent_at } = data.user return { identities, new_email, email_change_sent_at } } diff --git a/apps/studio/hooks/analytics/useLogsQuery.tsx b/apps/studio/hooks/analytics/useLogsQuery.tsx index e9bba8e4a30..0affe0b3c91 100644 --- a/apps/studio/hooks/analytics/useLogsQuery.tsx +++ b/apps/studio/hooks/analytics/useLogsQuery.tsx @@ -32,12 +32,17 @@ export interface LogsQueryHook { enabled?: boolean } -export const useLogsQuery = ( - projectRef: string, - initialParams: Partial = {}, +export const useLogsQuery = ({ + projectRef, + initialParams = {}, enabled = true, - options: { useOtel?: boolean } = {} -): LogsQueryHook => { + options = {}, +}: { + projectRef?: string + initialParams?: Partial + enabled?: boolean + options?: { useOtel?: boolean } +}): LogsQueryHook => { const { useOtel = false } = options const defaultHelper = getDefaultHelper(EXPLORER_DATEPICKER_HELPERS) const [params, setParams] = useState({ @@ -78,7 +83,7 @@ export const useLogsQuery = ( queryFn: async ({ signal }) => { const { data, error } = await get(logsAllEndpointUrl(useOtel), { params: { - path: { ref: projectRef }, + path: { ref: projectRef! }, query: params, }, signal, diff --git a/apps/studio/pages/project/[ref]/logs/explorer/index.tsx b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx index 87522878e74..e25431adb4a 100644 --- a/apps/studio/pages/project/[ref]/logs/explorer/index.tsx +++ b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx @@ -91,16 +91,16 @@ const MISSING_LIMIT_ERROR: LogQueryError = { export const LogsExplorerPage: NextPageWithLayout = () => { useEditorHints() + const track = useTrack() const monaco = useMonaco() const router = useRouter() const { profile } = useProfile() + const { ref: projectRef, q, queryId } = useParams() + const useOtelEndpoint = useFlag('otelLegacyLogs') + const { logsShowMetadataIpTemplate } = useIsFeatureEnabled(['logs:show_metadata_ip_template']) + const { data: project } = useSelectedProjectQuery() const { data: organization } = useSelectedOrganizationQuery() - const { ref, q, queryId } = useParams() - const track = useTrack() - const projectRef = ref as string - const { logsShowMetadataIpTemplate } = useIsFeatureEnabled(['logs:show_metadata_ip_template']) - const useOtelEndpoint = useFlag('otelLegacyLogs') const allTemplates = useMemo(() => { const templates = getLogsTemplates(useOtelEndpoint) @@ -158,10 +158,7 @@ export const LogsExplorerPage: NextPageWithLayout = () => { const { getEntitlementNumericValue } = useCheckEntitlements('log.retention_days') const entitledToAuditLogDays = getEntitlementNumericValue() - const { data: content } = useContentQuery({ - projectRef: ref, - type: 'log_sql', - }) + const { data: content } = useContentQuery({ projectRef, type: 'log_sql' }) const query = content?.content.find((x) => x.id === queryId) const resolvedRange = useMemo(() => { @@ -180,15 +177,15 @@ export const LogsExplorerPage: NextPageWithLayout = () => { error, isLoading: logsLoading, setParams, - } = useLogsQuery( + } = useLogsQuery({ projectRef, - { + initialParams: { iso_timestamp_start: resolvedRange.from, iso_timestamp_end: resolvedRange.to, }, - true, - { useOtel: useOtelEndpoint } - ) + enabled: true, + options: { useOtel: useOtelEndpoint }, + }) const results = logData const isLoading = logsLoading @@ -258,11 +255,11 @@ export const LogsExplorerPage: NextPageWithLayout = () => { const headerData = await constructHeaders() const source = detectLogSource(currentSql) const availableKeys = source - ? await fetchOtelLogKeys({ projectRef, source }).catch(() => undefined) + ? await fetchOtelLogKeys({ projectRef: projectRef!, source }).catch(() => undefined) : undefined const rewritten = await rewriteLogsSqlWithAI({ sql: currentSql, - projectRef, + projectRef: projectRef!, connectionString: project?.connectionString, orgSlug: organization?.slug, authorizationHeader: headerData.get('Authorization'), @@ -566,7 +563,7 @@ export const LogsExplorerPage: NextPageWithLayout = () => { hasEditorValue={Boolean(editorValue)} data={showMissingLimitError ? [] : results} error={showMissingLimitError ? MISSING_LIMIT_ERROR : error} - projectRef={projectRef} + projectRef={projectRef!} onSelectedLogChange={setSelectedLog} selectedLog={selectedLog || undefined} sqlQuery={editorValue} diff --git a/packages/common/auth.tsx b/packages/common/auth.tsx index 7caf2ab95f4..24a7f9e4bc3 100644 --- a/packages/common/auth.tsx +++ b/packages/common/auth.tsx @@ -128,7 +128,16 @@ export const useAuth = () => useContext(AuthContext) export const useSession = () => useAuth().session -export const useUser = () => useSession()?.user ?? null +export const useUser = () => { + const user = useSession()?.user ?? null + + // auth-js substitutes a throwing proxy for `user` when it was configured with + // `userStorage` and hasn't migrated the user into storage yet. Treat that as + // "no user" instead of crashing whenever a property on it is read. + if (user && (user as any).__isUserNotAvailableProxy) return null + + return user +} export const useIsUserLoading = () => useAuth().isLoading diff --git a/packages/common/feature-flags.tsx b/packages/common/feature-flags.tsx index 1c44c639a9d..9761584dc64 100644 --- a/packages/common/feature-flags.tsx +++ b/packages/common/feature-flags.tsx @@ -4,7 +4,7 @@ import { components } from 'api-types' import { FlagValues } from 'flags/react' import { createContext, PropsWithChildren, useContext, useEffect, useRef, useState } from 'react' -import { useAuth } from './auth' +import { useAuth, useUser } from './auth' import { getFlags as getDefaultConfigCatFlags } from './configcat' import { hasConsented } from './consent-state' import { get, post } from './fetchWrappers' @@ -90,8 +90,9 @@ export const FeatureFlagProvider = ({ userEmail?: string ) => Promise<{ settingKey: string; settingValue: boolean | number | string | null | undefined }[]> }>) => { - const { session, isLoading } = useAuth() - const userEmail = session?.user?.user_metadata.email ?? session?.user?.email + const { isLoading } = useAuth() + const user = useUser() + const userEmail = user?.user_metadata?.email ?? user?.email const params = useParams() const resolvedOrganizationSlug = organizationSlug ?? params.slug const resolvedProjectRef = projectRef ?? params.ref @@ -110,7 +111,7 @@ export const FeatureFlagProvider = ({ async function ensureGroupContext() { if (!API_URL) return - const userId = session?.user?.id + const userId = user?.id if (!userId) return if (!hasConsented()) return @@ -245,7 +246,7 @@ export const FeatureFlagProvider = ({ isLoading, userEmail, API_URL, - session?.user?.id, + user?.id, resolvedOrganizationSlug, resolvedProjectRef, getConfigCatFlags, From 26248be75332961264513e19b305ebb0940f16c7 Mon Sep 17 00:00:00 2001 From: Chris Chinchilla Date: Wed, 8 Jul 2026 20:41:39 +0200 Subject: [PATCH 31/54] docs: Add AI Tools to QuickStarts (#47684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## Summary Adds two new optional onboarding steps — **Install Agent Skills** and **Install MCP server** — to every framework quickstart guide, right after the "create app" step, so readers are pointed at [Agent Skills](/docs/guides/ai-tools/ai-skills) and the [Supabase MCP server](/docs/guides/ai-tools/mcp) early in the setup flow. **Where each step lives:** - **16 quickstarts that include the shared `quickstart_db_setup.mdx` partial** (Next.js, Astro, Expo/React Native, Flask, Flutter, Hono, iOS/SwiftUI, Kotlin, Laravel, Nuxt, React, Refine, SolidJS, SvelteKit, TanStack Start, Vue): the partial itself now has a step 2 "Install MCP server (optional)" (between project creation and database setup), and each individual file gets its own "Install Agent Skills (optional)" step right after its app-creation step. - **RedwoodJS and Ruby on Rails** (don't use the shared partial): got both steps added inline, in the same order (Agent Skills, then MCP server), since they can't inherit from the partial. - All subsequent step numbers (and the "Step N" cross-references in prose, e.g. in RedwoodJS) were renumbered to stay sequential. ## Test plan - Check the quickstarts locally or in preview. - Any other ideas on how to optimise showing these items? - Does the SQL prefill add anything? - Other ideas on how to simplify without losing the information? - Check the MD output too and see if that also makes sense. ## Summary by CodeRabbit * **Documentation** * Refreshed multiple getting-started quickstarts with consistent, clearer step sequencing (including renumbering) across frameworks. * Added an optional “Install Agent Skills” step where applicable, plus updated placements of shared environment-variable setup content. * Simplified the database quickstart flow: single “Create a Supabase project” step, streamlined SQL Editor instructions for creating an `instruments` table, enabling RLS, and granting public read access. * Added optional “Install MCP server” steps in the relevant quickstarts. * **Style** * Updated MDX linting rules to allow the uppercase phrase “Agent Skills”. --------- Co-authored-by: Jeremias Menichelli Co-authored-by: Nik Richers --- .../content/_partials/quickstart_db_setup.mdx | 61 ++++--------------- .../getting-started/quickstarts/astrojs.mdx | 18 ++++-- .../quickstarts/expo-react-native.mdx | 18 ++++-- .../getting-started/quickstarts/flask.mdx | 18 ++++-- .../getting-started/quickstarts/flutter.mdx | 18 ++++-- .../getting-started/quickstarts/hono.mdx | 20 ++++-- .../quickstarts/ios-swiftui.mdx | 18 ++++-- .../getting-started/quickstarts/kotlin.mdx | 18 ++++-- .../getting-started/quickstarts/laravel.mdx | 12 +++- .../getting-started/quickstarts/nextjs.mdx | 16 ++++- .../getting-started/quickstarts/nuxtjs.mdx | 18 ++++-- .../getting-started/quickstarts/reactjs.mdx | 18 ++++-- .../getting-started/quickstarts/redwoodjs.mdx | 34 ++++++++--- .../getting-started/quickstarts/refine.mdx | 16 ++++- .../quickstarts/ruby-on-rails.mdx | 20 ++++-- .../getting-started/quickstarts/solidjs.mdx | 18 ++++-- .../getting-started/quickstarts/sveltekit.mdx | 18 ++++-- .../getting-started/quickstarts/tanstack.mdx | 18 ++++-- .../getting-started/quickstarts/vue.mdx | 18 ++++-- supa-mdx-lint/Rule001HeadingCase.toml | 1 + 20 files changed, 272 insertions(+), 124 deletions(-) diff --git a/apps/docs/content/_partials/quickstart_db_setup.mdx b/apps/docs/content/_partials/quickstart_db_setup.mdx index e639c4e05c8..08e577f4446 100644 --- a/apps/docs/content/_partials/quickstart_db_setup.mdx +++ b/apps/docs/content/_partials/quickstart_db_setup.mdx @@ -25,72 +25,33 @@ using (true);` ## 1. Create a Supabase project -Before you can use Supabase, you need a Supabase project. You can create a project visually in the Dashboard or programmatically using [the Management API](/docs/reference/api/introduction). - - - - +To start, you need a Supabase project. Create a new Supabase project from [the Dashboard of any organization](/dashboard/new/_) you belong to. - + -You can also use [database.new](https://database.new) to create a new Supabase project. +Use [the Management API](/docs/reference/api/v1-create-a-project) or ask [the MCP server](/docs/guides/ai-tools/mcp#account-management) to create a new Supabase project. - - - - -First, get your access token from the [**Account Settings > Access Tokens**](/dashboard/account/tokens) section of the Dashboard. - -Then create a project using [the Management API](/docs/reference/api/introduction): - -```bash -export SUPABASE_ACCESS_TOKEN="your-access-token" - -# List your organizations to get the organization ID -curl -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ - https://api.supabase.com/v1/organizations - -# Create a new project (replace with your organization ID) -curl -X POST https://api.supabase.com/v1/projects \ - -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "organization_id": "", - "name": "My Project", - "region": "us-east-1", - "db_pass": "" - }' -``` - - - - - -## 2. Install agent skills (optional) - -[Agent Skills](/docs/guides/ai-tools/ai-skills) are curated instruction sets that give your AI agent procedural knowledge about working with Supabase. - -Install them in your project with: - -```bash -npx skills add supabase/agent-skills -``` - -## 3. Set up your database +## 2. Set up your database When your Supabase project is up and running, create an `instruments` table with some sample data. Then set only the privileges each Postgres role needs, add [Row Level Security (RLS)](/docs/guides/database/postgres/row-level-security) for enhanced security for database data by default, and create an RLS policy to make the data in the table publicly readable. +Do these steps within your project's dashboard by copying and running the snippet in your project's [SQL Editor](/dashboard/project/_/sql/new). + Save some steps by clicking here to prefill the SQL in the SQL Editor, and then clicking **Run**. -Or do this manually in your project's [SQL Editor](/dashboard/project/_/sql/new), by pasting the SQL below, and clicking **Run**. + + +You can use [the Management API](/docs/reference/api/v1-run-a-query) or ask [the MCP server](/docs/guides/ai-tools/mcp#database) to execute SQL queries. + + ```sql SQL_EDITOR -- Create the table diff --git a/apps/docs/content/guides/getting-started/quickstarts/astrojs.mdx b/apps/docs/content/guides/getting-started/quickstarts/astrojs.mdx index 6b4665af590..392227c9fb6 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/astrojs.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/astrojs.mdx @@ -6,7 +6,7 @@ breadcrumb: 'Framework Quickstarts' <$Partial path="quickstart_db_setup.mdx" /> -## 4. Create an Astro app +## 3. Create an Astro app Create an Astro app using the `npm create` command. @@ -15,6 +15,16 @@ npm create astro@latest my-app cd my-app ``` +## 4. Install Agent Skills (optional) + +Supabase's [Agent Skills](/docs/guides/ai-tools/ai-skills) is a curated set of instructions that give your AI agent procedural knowledge about working with Supabase. + +To install, run the following command in the root of your project: + +```bash +npx skills add supabase/agent-skills +``` + ## 5. Install Supabase client library and Node adapter Install the `supabase-js` client library and the `@astrojs/node` adapter to enable server-side rendering. @@ -39,11 +49,9 @@ export default defineConfig({ }) ``` -<$Partial path="api_settings.mdx" variables={{ "framework": "astro", "tab": "frameworks" }} /> - ## 7. Declare Supabase environment variables -Create a `.env.local` file and populate with your Supabase connection variables that you can get from the helper above, or [from the project **Connect** panel](/dashboard/project/_?showConnect=true&framework=astro&tab=frameworks): +Create a `.env.local` file and populate with your Supabase connection variables that you can get from the helper below, or [from the project **Connect** panel](/dashboard/project/_?showConnect=true&framework=astro&tab=frameworks):
-
+
+
onUpdateColumn({ name: event.target.value })} /> + {relations.filter((r) => !r.toRemove).length === 0 ? ( - +
+ +
+ + + + + + + + {column.isSensitiveData + ? 'Data is masked in grid display. Actual data unchanged in database.' + : 'Mark as sensitive to mask in grid display'} + + +
) : ( diff --git a/apps/studio/state/table-editor-table.tsx b/apps/studio/state/table-editor-table.tsx index 99b49e1e40b..010e6c67259 100644 --- a/apps/studio/state/table-editor-table.tsx +++ b/apps/studio/state/table-editor-table.tsx @@ -17,6 +17,12 @@ import { Entity } from '@/data/table-editor/table-editor-types' const FALLBACK_TABLE_STATE = proxy({}) as TableEditorTableState +// Helper to extract sensitive data marker from column comment +const SENSITIVE_DATA_MARKER = '[SENSITIVE]' +const isSensitiveDataColumn = (comment: string | null | undefined): boolean => { + return comment ? comment.includes(SENSITIVE_DATA_MARKER) : false +} + export const createTableEditorTableState = ({ projectRef, table: originalTable, @@ -49,6 +55,9 @@ export const createTableEditorTableState = ({ savedState ) + // userToggledColumns: columns user has explicitly toggled OFF (persisted) + const userToggledColumns = new Set((savedState as any)?.sensitiveDataColumns ?? []) + const state = proxy({ /* Table */ table, @@ -74,6 +83,14 @@ export const createTableEditorTableState = ({ { gridColumns: state.gridColumns } ) + // Preserve user's toggle choices across table updates + // Only clear toggles for columns that no longer exist + const currentColumnNames = new Set(table.columns.map((col) => col.name)) + const preservedToggles = new Set( + Array.from(state.userToggledSensitiveColumns).filter((col) => currentColumnNames.has(col)) + ) + state.userToggledSensitiveColumns = proxySet(preservedToggles) + state.table = supaTable state.gridColumns = gridColumns state.originalTable = table @@ -94,6 +111,29 @@ export const createTableEditorTableState = ({ /* Columns */ gridColumns, + userToggledSensitiveColumns: proxySet(userToggledColumns), + temporarilyRevealedColumns: proxySet(), + toggleSensitiveDataColumn: (columnKey: string) => { + // Track which columns user has toggled OFF from their default masked state + if (state.userToggledSensitiveColumns.has(columnKey)) { + state.userToggledSensitiveColumns.delete(columnKey) + } else { + state.userToggledSensitiveColumns.add(columnKey) + } + }, + get sensitiveDataColumns() { + // Single source of truth: columns marked sensitive = defaults minus user toggles (persistent only) + const defaultSensitiveColumns = new Set( + state.table.columns + .filter((col) => isSensitiveDataColumn(col.comment)) + .map((col) => col.name) + ) + return new Set( + Array.from(defaultSensitiveColumns).filter( + (col) => !state.userToggledSensitiveColumns.has(col) + ) + ) + }, moveColumn: (fromKey: string, toKey: string) => { const fromIdx = state.gridColumns.findIndex((x) => x.key === fromKey) const toIdx = state.gridColumns.findIndex((x) => x.key === toKey) @@ -250,7 +290,8 @@ export const TableEditorTableStateContextProvider = ({ gridColumns: state.gridColumns, projectRef, tableId: state.table.id, - }) + sensitiveDataColumns: Array.from(state.userToggledSensitiveColumns), + } as any) }) } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/e2e/studio/features/table-editor-sensitive-data.spec.ts b/e2e/studio/features/table-editor-sensitive-data.spec.ts new file mode 100644 index 00000000000..936df77e62d --- /dev/null +++ b/e2e/studio/features/table-editor-sensitive-data.spec.ts @@ -0,0 +1,189 @@ +import crypto from 'node:crypto' +import { expect } from '@playwright/test' + +import { createTable, dropTable, query } from '../utils/db/index.js' +import { test } from '../utils/test.js' +import { toUrl } from '../utils/to-url.js' +import { waitForGridDataToLoad } from '../utils/wait-for-response.js' + +const uniqueSuffix = () => crypto.randomBytes(4).toString('hex') + +const SENSITIVE_MARKER = '[SENSITIVE]' +const MASKED_VALUE = '••••••••' +const SECRET_VALUE = 'super_secret_value' +const PUBLIC_VALUE = 'public_value' + +/** + * Creates a table with two columns: + * - `public_col` (plain text) + * - `secret_col` (text, comment contains [SENSITIVE] so it is masked by default) + * Inserts one row so the grid has data to assert against. + */ +const setupSensitiveFixture = async () => { + const suffix = uniqueSuffix() + const tableName = `pw_sensitive_${suffix}` + + await createTable(tableName, 'public_col', [{ public_col: PUBLIC_VALUE }]) + await query(`ALTER TABLE public.${tableName} ADD COLUMN secret_col text`, []) + await query(`COMMENT ON COLUMN public.${tableName}.secret_col IS '${SENSITIVE_MARKER}'`, []) + await query(`UPDATE public.${tableName} SET secret_col = $1`, [SECRET_VALUE]) + + return { + tableName, + async [Symbol.asyncDispose]() { + await dropTable(tableName) + }, + } +} + +const goToTable = async ( + page: Parameters[0], + ref: string, + tableName: string +) => { + await page.goto(toUrl(`/project/${ref}/editor?schema=public`)) + await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click() + await page.waitForURL(/\/editor\/\d+\?schema=public$/) + await waitForGridDataToLoad(page, ref) +} + +const openColumnMenu = async ( + page: Parameters[0], + colName: string +) => { + await page + .getByRole('columnheader', { name: colName }) + .getByRole('button', { name: `Column ${colName} actions` }) + .click() +} + +test.describe('table editor — sensitive data masking', () => { + test('sensitive column shows masked value in grid by default', async ({ page, ref }) => { + await using fixture = await setupSensitiveFixture() + await goToTable(page, ref, fixture.tableName) + + await expect( + page.getByRole('gridcell', { name: PUBLIC_VALUE }), + 'public column value should be visible' + ).toBeVisible() + + await expect( + page.getByRole('gridcell', { name: MASKED_VALUE }), + 'sensitive column should show masked value' + ).toBeVisible() + + await expect( + page.getByRole('gridcell', { name: SECRET_VALUE }), + 'actual sensitive value should not be visible' + ).not.toBeVisible() + }) + + test('column menu shows "Show data" only for sensitive columns', async ({ page, ref }) => { + await using fixture = await setupSensitiveFixture() + await goToTable(page, ref, fixture.tableName) + + await openColumnMenu(page, 'secret_col') + await expect( + page.getByRole('menuitem', { name: 'Show data' }), + '"Show data" should appear in the menu for a sensitive column' + ).toBeVisible() + await page.keyboard.press('Escape') + + await openColumnMenu(page, 'public_col') + await expect( + page.getByRole('menuitem', { name: 'Show data' }), + '"Show data" should not appear for a non-sensitive column' + ).not.toBeVisible() + await page.keyboard.press('Escape') + }) + + test('"Show data" menu item is disabled while column is temporarily revealed', async ({ + page, + ref, + }) => { + await using fixture = await setupSensitiveFixture() + await goToTable(page, ref, fixture.tableName) + + // Reveal the sensitive data + await openColumnMenu(page, 'secret_col') + await page.getByRole('menuitem', { name: 'Show data' }).click() + + // Wait for the dropdown to fully unmount before reopening it — a trigger + // click that lands while the previous menu is still closing is dropped + await page.getByRole('menu').waitFor({ state: 'detached' }) + + await openColumnMenu(page, 'secret_col') + + // ColumnMenu.tsx relabels the item to "Data revealed (5s)" while the + // column is temporarily revealed, so it must be located by that name here + await expect( + page.getByRole('menuitem', { name: 'Data revealed (5s)' }), + '"Show data" menu item should be disabled while column is temporarily revealed' + ).toBeDisabled({ timeout: 2000 }) + + await page.keyboard.press('Escape') + }) + + test('sensitive data is automatically masked again after 5 seconds', async ({ page, ref }) => { + await using fixture = await setupSensitiveFixture() + await goToTable(page, ref, fixture.tableName) + + // Reveal the sensitive data + await openColumnMenu(page, 'secret_col') + await page.getByRole('menuitem', { name: 'Show data' }).click() + + await expect( + page.getByRole('gridcell', { name: SECRET_VALUE }), + 'actual value should be visible after reveal' + ).toBeVisible({ timeout: 5000 }) + + // Wait for the temporary reveal to expire + await page.waitForTimeout(5500) + + await expect( + page.getByRole('gridcell', { name: MASKED_VALUE }), + 'sensitive value should be masked again after 5 seconds' + ).toBeVisible() + + await expect( + page.getByRole('gridcell', { name: SECRET_VALUE }), + 'actual sensitive value should no longer be visible' + ).not.toBeVisible() + }) + + test('copying a cell from a sensitive column shows a warning toast', async ({ page, ref }) => { + await using fixture = await setupSensitiveFixture() + await goToTable(page, ref, fixture.tableName) + + // Reveal first so there is actual text in the cell to right-click + await openColumnMenu(page, 'secret_col') + await page.getByRole('menuitem', { name: 'Show data' }).click() + await expect(page.getByRole('gridcell', { name: SECRET_VALUE })).toBeVisible({ timeout: 5000 }) + + const cell = page.getByRole('gridcell', { name: SECRET_VALUE }) + await cell.click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Copy cell' }).click() + + await expect( + page.getByText('Copied sensitive data to clipboard'), + 'warning toast should appear when copying a sensitive cell' + ).toBeVisible({ timeout: 10000 }) + }) + + test('copying a row that contains a sensitive column shows a warning toast', async ({ + page, + ref, + }) => { + await using fixture = await setupSensitiveFixture() + await goToTable(page, ref, fixture.tableName) + + const cell = page.getByRole('gridcell', { name: PUBLIC_VALUE }) + await cell.click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Copy row' }).click() + + await expect( + page.getByText('Copied row containing sensitive data to clipboard'), + 'warning toast should appear when copying a row with a sensitive column' + ).toBeVisible({ timeout: 10000 }) + }) +}) From c76a3006dee3c97b1fac3f4c517d86723410be6f Mon Sep 17 00:00:00 2001 From: jose-ledesma <52402874+jose-ledesma@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:09:42 +0200 Subject: [PATCH 36/54] chore: remove old group references from CODEOWNERS (#47763) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR cleans up CODEOWNERS after the infrastructure teams have been renamed. Removes old group references: - @supabase/infra → (removed, now @supabase/platform) The new group names are now in use and working correctly. ## Summary by CodeRabbit * **Chores** * Updated ownership settings for one shared data area to reflect the current team responsible for it. --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3450bcab20b..79e3919a771 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -21,4 +21,4 @@ /apps/studio/components/interfaces/Organization/Documents/ @supabase/security /apps/studio/pages/new/index.tsx @supabase/security -/packages/shared-data/compute-disk-limits.ts @supabase/infra @supabase/platform +/packages/shared-data/compute-disk-limits.ts @supabase/platform From 9e17c41771fb8c05ff0110f29d9a4147a656397f Mon Sep 17 00:00:00 2001 From: Monica Khoury <99693443+monicakh@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:41:16 +0300 Subject: [PATCH 37/54] =?UTF-8?q?Chore:=20Link=20Delete=20Project=20doc=20?= =?UTF-8?q?from=20the=20Delete=20Project=20confirmation=20m=E2=80=A6=20(#4?= =?UTF-8?q?7637)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes FE-3801. ## Summary by CodeRabbit * **Bug Fixes** * Updated the project deletion confirmation dialog so the “learn more / documentation” text and external link are always shown consistently. * Paid projects now include the full warning (“All project data will be lost, and cannot be undone.”) alongside the documentation link, while free projects show the simplified message without the additional warning. --------- Co-authored-by: Joshen Lim --- .../DeleteProjectPanel/DeleteProjectModal.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx index c3af8e4f169..759d36847e5 100644 --- a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx +++ b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx @@ -121,7 +121,21 @@ export const DeleteProjectModal = ({ title: isFree ? 'This action cannot be undone.' : `This will permanently delete the ${project?.name}`, - description: !isFree ? `All project data will be lost, and cannot be undone` : '', + description: ( + <> + {!isFree && 'All project data will be lost, and cannot be undone. '} + Read the{' '} +
+ documentation + {' '} + for prerequisites, implications, and recovery information. + + ), }} text={ isFree From 82495cb45545eb074b61460ce4e1c76cff6fd393 Mon Sep 17 00:00:00 2001 From: shaziya <99940835+shaziyabandukia@users.noreply.github.com> Date: Thu, 9 Jul 2026 01:49:07 -0700 Subject: [PATCH 38/54] blog: Searchable field-level encryption on Supabase with CipherStash (#47751) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? ### Blog post: Searchable field-level encryption on Supabase with CipherStash Partner drop announcing the CipherStash integration. **Scheduled to go live July 9, 2026.** - Author: `bilharmer` (title corrected to CISO) - Category: `product` - URL: `/blog/searchable-field-level-encryption-with-cipherstash` ### Outstanding before merge - [x] Marketing +1 in `#team-marketing`. ## Summary by CodeRabbit * **Documentation** * Published a new blog post about searchable field-level encryption with Supabase and CipherStash, including setup guidance, integration details, and a video walkthrough. * **Content Updates** * Updated an author profile title from “CSO” to “CISO.” --- ...ield-level-encryption-with-cipherstash.mdx | 59 ++++++++++++++++++ apps/www/lib/authors.json | 2 +- .../og.png | Bin 0 -> 414070 bytes .../thumb.png | Bin 0 -> 251045 bytes 4 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 apps/www/_blog/2026-07-09-searchable-field-level-encryption-with-cipherstash.mdx create mode 100644 apps/www/public/images/blog/searchable-field-level-encryption-with-cipherstash/og.png create mode 100644 apps/www/public/images/blog/searchable-field-level-encryption-with-cipherstash/thumb.png diff --git a/apps/www/_blog/2026-07-09-searchable-field-level-encryption-with-cipherstash.mdx b/apps/www/_blog/2026-07-09-searchable-field-level-encryption-with-cipherstash.mdx new file mode 100644 index 00000000000..d51b501164a --- /dev/null +++ b/apps/www/_blog/2026-07-09-searchable-field-level-encryption-with-cipherstash.mdx @@ -0,0 +1,59 @@ +--- +title: 'Searchable field-level encryption on Supabase with CipherStash' +description: 'Add field-level encryption to your Supabase project with CipherStash. Searchable ciphertext, zero-knowledge key management, and no schema changes required.' +author: bilharmer +imgSocial: searchable-field-level-encryption-with-cipherstash/og.png +imgThumb: searchable-field-level-encryption-with-cipherstash/thumb.png +categories: + - product +tags: + - postgres + - security + - encryption + - integrations +date: '2026-07-09T10:00:00' +toc_depth: 2 +--- + +The [CipherStash integration](https://supabase.com/partners/integrations/cipherstash) for Supabase is now available. + +[CipherStash](https://cipherstash.com) is a Data Level Access Control (DLAC) platform for applications built on Postgres. DLAC extends traditional access control, which typically operates at the row or table level, down to individual encrypted values, so policies are enforced at decryption rather than at the query layer. + +With CipherStash, teams can encrypt sensitive fields at the application layer with a unique key per value, run searches and joins against encrypted data without decrypting it, and keep keys under their own control through ZeroKMS, CipherStash's zero-knowledge key management service. Because keys never leave your control, neither CipherStash nor Supabase can access your plaintext data. + +The Supabase integration enables teams to add field-level encryption to any Supabase project using the encrypted Supabase SDK wrapper without changing the schema. Setup is one CLI command: `npx stash init --supabase`. + +
+