diff --git a/apps/docs/app/api/search/cors.ts b/apps/docs/app/api/search/cors.ts new file mode 100644 index 0000000000000..329df5ef61737 --- /dev/null +++ b/apps/docs/app/api/search/cors.ts @@ -0,0 +1,5 @@ +export const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'content-type', +} diff --git a/apps/docs/app/api/search/embeddings/route.test.ts b/apps/docs/app/api/search/embeddings/route.test.ts new file mode 100644 index 0000000000000..8cf06aa5c0b8b --- /dev/null +++ b/apps/docs/app/api/search/embeddings/route.test.ts @@ -0,0 +1,56 @@ +import { NextRequest } from 'next/server' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { POST } from './route' + +const isFeatureEnabledMock = vi.fn().mockReturnValue(true) +vi.mock('common/enabled-features', () => ({ + isFeatureEnabled: (...args: unknown[]) => isFeatureEnabledMock(...args), +})) + +const fetchMock = vi.fn() +vi.stubGlobal('fetch', fetchMock) + +function makeRequest(body: unknown) { + return new NextRequest('https://example.com/api/search/embeddings', { + method: 'POST', + body: JSON.stringify(body), + }) +} + +describe('/api/search/embeddings', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + it('returns 400 when query is missing', async () => { + const response = await POST(makeRequest({})) + expect(response.status).toBe(400) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('forwards the query and feature flag to the search-embeddings function', async () => { + isFeatureEnabledMock.mockReturnValue(false) + fetchMock.mockResolvedValue( + new Response(JSON.stringify([{ id: 1, path: '/guides/test' }]), { status: 200 }) + ) + + const response = await POST(makeRequest({ query: 'realtime' })) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] + expect(url).toContain('/functions/v1/search-embeddings') + expect(JSON.parse(init.body)).toEqual({ query: 'realtime', useAlternateSearchIndex: true }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual([{ id: 1, path: '/guides/test' }]) + }) + + it('propagates the upstream status on error', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ error: 'boom' }), { status: 500 })) + + const response = await POST(makeRequest({ query: 'realtime' })) + + expect(response.status).toBe(500) + expect(await response.json()).toEqual({ error: 'boom' }) + }) +}) diff --git a/apps/docs/app/api/search/embeddings/route.ts b/apps/docs/app/api/search/embeddings/route.ts new file mode 100644 index 0000000000000..06c6b58bb4ac1 --- /dev/null +++ b/apps/docs/app/api/search/embeddings/route.ts @@ -0,0 +1,26 @@ +import * as Sentry from '@sentry/nextjs' +import { type NextRequest } from 'next/server' + +import { corsHeaders } from '../cors' +import { _handleEmbeddingsSearchRequest } from './route.utils' + +export const runtime = 'edge' + +export async function OPTIONS() { + return new Response(null, { headers: corsHeaders }) +} + +export async function POST(request: NextRequest) { + try { + const response = await _handleEmbeddingsSearchRequest(request) + Object.entries(corsHeaders).forEach(([key, value]) => response.headers.set(key, value)) + return response + } catch (error) { + console.error('Error handling docs embeddings search request:', error) + Sentry.captureException(error, { tags: { route: 'search-embeddings' } }) + return Response.json( + { error: 'There was an error processing your request' }, + { status: 500, headers: corsHeaders } + ) + } +} diff --git a/apps/docs/app/api/search/embeddings/route.utils.ts b/apps/docs/app/api/search/embeddings/route.utils.ts new file mode 100644 index 0000000000000..6b0ee49c21337 --- /dev/null +++ b/apps/docs/app/api/search/embeddings/route.utils.ts @@ -0,0 +1,33 @@ +import * as Sentry from '@sentry/nextjs' +import { isFeatureEnabled } from 'common/enabled-features' +import { type NextRequest } from 'next/server' + +const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL + +export async function _handleEmbeddingsSearchRequest(request: NextRequest) { + const { query } = await request.json() + + if (!query || typeof query !== 'string') { + return Response.json({ error: 'Missing query in request data' }, { status: 400 }) + } + + const useAlternateSearchIndex = !isFeatureEnabled('search:fullIndex') + + const response = await fetch(`${SUPABASE_URL}/functions/v1/search-embeddings`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ query, useAlternateSearchIndex }), + }) + + const data = await response.json() + + if (!response.ok) { + console.error('Error running docs embeddings search:', data) + Sentry.captureException(new Error(data?.error ?? 'search-embeddings request failed'), { + tags: { route: 'search-embeddings' }, + extra: { query, status: response.status, data }, + }) + } + + return Response.json(data, { status: response.status }) +} diff --git a/apps/docs/app/api/search/fts/route.test.ts b/apps/docs/app/api/search/fts/route.test.ts new file mode 100644 index 0000000000000..04d947204c15f --- /dev/null +++ b/apps/docs/app/api/search/fts/route.test.ts @@ -0,0 +1,62 @@ +import { NextRequest } from 'next/server' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { POST } from './route' + +const rpcSpy = vi.fn() +vi.mock('~/lib/supabase', () => ({ + supabase: () => ({ rpc: rpcSpy }), +})) + +const isFeatureEnabledMock = vi.fn().mockReturnValue(true) +vi.mock('common/enabled-features', () => ({ + isFeatureEnabled: (...args: unknown[]) => isFeatureEnabledMock(...args), +})) + +function makeRequest(body: unknown) { + return new NextRequest('https://example.com/api/search/fts', { + method: 'POST', + body: JSON.stringify(body), + }) +} + +describe('/api/search/fts', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + it('returns 400 when query is missing', async () => { + const response = await POST(makeRequest({})) + expect(response.status).toBe(400) + expect(rpcSpy).not.toHaveBeenCalled() + }) + + it('calls docs_search_fts when the full index feature is enabled', async () => { + isFeatureEnabledMock.mockReturnValue(true) + rpcSpy.mockResolvedValue({ data: [{ id: 1, path: '/guides/test' }], error: null }) + + const response = await POST(makeRequest({ query: ' realtime ' })) + + expect(rpcSpy).toHaveBeenCalledWith('docs_search_fts', { query: 'realtime' }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual([{ id: 1, path: '/guides/test' }]) + }) + + it('calls docs_search_fts_nimbus when the full index feature is disabled', async () => { + isFeatureEnabledMock.mockReturnValue(false) + rpcSpy.mockResolvedValue({ data: [], error: null }) + + await POST(makeRequest({ query: 'realtime' })) + + expect(rpcSpy).toHaveBeenCalledWith('docs_search_fts_nimbus', { query: 'realtime' }) + }) + + it('returns 500 when the RPC errors', async () => { + rpcSpy.mockResolvedValue({ data: null, error: { message: 'boom' } }) + + const response = await POST(makeRequest({ query: 'realtime' })) + + expect(response.status).toBe(500) + expect(await response.json()).toEqual({ error: 'boom' }) + }) +}) diff --git a/apps/docs/app/api/search/fts/route.ts b/apps/docs/app/api/search/fts/route.ts new file mode 100644 index 0000000000000..f9c9dc0dbe14d --- /dev/null +++ b/apps/docs/app/api/search/fts/route.ts @@ -0,0 +1,26 @@ +import * as Sentry from '@sentry/nextjs' +import { type NextRequest } from 'next/server' + +import { corsHeaders } from '../cors' +import { _handleFtsSearchRequest } from './route.utils' + +export const runtime = 'edge' + +export async function OPTIONS() { + return new Response(null, { headers: corsHeaders }) +} + +export async function POST(request: NextRequest) { + try { + const response = await _handleFtsSearchRequest(request) + Object.entries(corsHeaders).forEach(([key, value]) => response.headers.set(key, value)) + return response + } catch (error) { + console.error('Error handling docs full-text search request:', error) + Sentry.captureException(error, { tags: { route: 'search-fts' } }) + return Response.json( + { error: 'There was an error processing your request' }, + { status: 500, headers: corsHeaders } + ) + } +} diff --git a/apps/docs/app/api/search/fts/route.utils.ts b/apps/docs/app/api/search/fts/route.utils.ts new file mode 100644 index 0000000000000..bfc947ef2be7a --- /dev/null +++ b/apps/docs/app/api/search/fts/route.utils.ts @@ -0,0 +1,28 @@ +import * as Sentry from '@sentry/nextjs' +import { supabase } from '~/lib/supabase' +import { isFeatureEnabled } from 'common/enabled-features' +import { type NextRequest } from 'next/server' + +export async function _handleFtsSearchRequest(request: NextRequest) { + const { query } = await request.json() + + if (!query || typeof query !== 'string') { + return Response.json({ error: 'Missing query in request data' }, { status: 400 }) + } + + const useAlternateSearchIndex = !isFeatureEnabled('search:fullIndex') + const searchFunction = useAlternateSearchIndex ? 'docs_search_fts_nimbus' : 'docs_search_fts' + + const { data, error } = await supabase().rpc(searchFunction, { query: query.trim() }) + + if (error) { + console.error('Error running docs full-text search:', error) + Sentry.captureException(new Error(error.message), { + tags: { route: 'search-fts' }, + extra: { query, searchFunction, error }, + }) + return Response.json({ error: error.message }, { status: 500 }) + } + + return Response.json(data) +} diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 70effd01a41c3..196a9cb40e8ee 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -3073,34 +3073,34 @@ export const self_hosting: NavMenuConstant = { url: '/guides/self-hosting', items: [ { name: 'Overview', url: '/guides/self-hosting' }, - { name: 'Self-Hosting with Docker', url: '/guides/self-hosting/docker' }, + { name: 'Deploy with Docker', url: '/guides/self-hosting/docker' }, + { name: 'Configure new API keys', url: '/guides/self-hosting/self-hosted-auth-keys' }, + { name: 'Enable Envoy API Gateway', url: '/guides/self-hosting/self-hosted-envoy' }, + { + name: 'Add Reverse Proxy with HTTPS', + url: '/guides/self-hosting/self-hosted-proxy-https', + }, { name: 'How-to Guides', items: [ - { name: 'Configure new API keys', url: '/guides/self-hosting/self-hosted-auth-keys' }, - { name: 'Self-Hosted Functions', url: '/guides/self-hosting/self-hosted-functions' }, - { - name: 'Add Reverse Proxy with HTTPS', - url: '/guides/self-hosting/self-hosted-proxy-https', - }, - { name: 'Envoy API Gateway', url: '/guides/self-hosting/self-hosted-envoy' }, { name: 'Upgrade to Postgres 17', url: '/guides/self-hosting/postgres-upgrade-17' }, - { - name: 'Restore Project from Platform', - url: '/guides/self-hosting/restore-from-platform', - }, + { name: 'Remove superuser access', url: '/guides/self-hosting/remove-superuser-access' }, + { name: 'Run Self-Hosted Functions', url: '/guides/self-hosting/self-hosted-functions' }, { name: 'Configure S3 Storage', url: '/guides/self-hosting/self-hosted-s3' }, - { name: 'Copy Storage from Platform', url: '/guides/self-hosting/copy-from-platform-s3' }, - { name: 'Custom Email Templates', url: '/guides/self-hosting/custom-email-templates' }, + { name: 'Add Custom Email Templates', url: '/guides/self-hosting/custom-email-templates' }, { name: 'Configure Social Login (OAuth)', url: '/guides/self-hosting/self-hosted-oauth' }, { name: 'Configure Phone Login & MFA', url: '/guides/self-hosting/self-hosted-phone-mfa' }, { name: 'Configure SAML 2.0 SSO', url: '/guides/self-hosting/self-hosted-saml-sso' }, { name: 'Enable MCP server', url: '/guides/self-hosting/enable-mcp' }, - { name: 'Remove superuser access', url: '/guides/self-hosting/remove-superuser-access' }, { name: 'Custom Postgres Extensions', url: '/guides/self-hosting/custom-postgres-extensions', }, + { + name: 'Restore Project from Platform', + url: '/guides/self-hosting/restore-from-platform', + }, + { name: 'Copy Storage from Platform', url: '/guides/self-hosting/copy-from-platform-s3' }, ], }, { diff --git a/apps/docs/content/guides/self-hosting/custom-email-templates.mdx b/apps/docs/content/guides/self-hosting/custom-email-templates.mdx index f130534b30f9c..a01822846750d 100644 --- a/apps/docs/content/guides/self-hosting/custom-email-templates.mdx +++ b/apps/docs/content/guides/self-hosting/custom-email-templates.mdx @@ -1,7 +1,7 @@ --- title: 'Custom Email Templates' -description: 'Configure custom email templates with self-hosted Supabase instance' -subtitle: 'Configure custom email templates with self-hosted Supabase instance' +description: 'Configure custom email templates with self-hosted Supabase instance.' +subtitle: 'Configure custom email templates with self-hosted Supabase instance.' --- When running a self-hosted Supabase instance, you can fully customize emails sent by Supabase Auth. diff --git a/apps/docs/content/guides/self-hosting/docker.mdx b/apps/docs/content/guides/self-hosting/docker.mdx index 648701f8c0e88..60206269a651c 100644 --- a/apps/docs/content/guides/self-hosting/docker.mdx +++ b/apps/docs/content/guides/self-hosting/docker.mdx @@ -131,14 +131,11 @@ mkdir supabase-project # ├── supabase # └── supabase-project -# Copy the compose files over to your project -cp -rf supabase/docker/* supabase-project +# Copy the configuration to your project +cp -rf supabase/docker/. supabase-project -# Copy the example environment file -cp supabase/docker/.env.example supabase-project/.env - -# Switch to your project directory -cd supabase-project +# Switch to the project directory and create a .env from the example +cd supabase-project && cp .env.example .env # Pull the latest images docker compose pull @@ -167,14 +164,11 @@ mkdir supabase-project # ├── supabase # └── supabase-project -# Copy the compose files over to your project -cp -rf supabase/docker/* supabase-project - -# Copy the example environment file -cp supabase/docker/.env.example supabase-project/.env +# Copy the configuration over to your project +cp -rf supabase/docker/. supabase-project -# Switch to your project directory -cd supabase-project +# Switch to the project directory and create a .env from the example +cd supabase-project && cp .env.example .env # Pull the latest images docker compose pull diff --git a/apps/docs/content/guides/self-hosting/remove-superuser-access.mdx b/apps/docs/content/guides/self-hosting/remove-superuser-access.mdx index 896edba2f6b2c..b18be5ea5d4e7 100644 --- a/apps/docs/content/guides/self-hosting/remove-superuser-access.mdx +++ b/apps/docs/content/guides/self-hosting/remove-superuser-access.mdx @@ -1,5 +1,5 @@ --- -title: 'Remove superuser access from Studio' +title: 'Remove Superuser Access from Studio' description: 'Learn how to switch from the supabase_admin to postgres role in self-hosted Supabase.' subtitle: 'Learn how to switch from the supabase_admin to postgres role in self-hosted Supabase.' --- diff --git a/apps/docs/content/troubleshooting/realtime-client-presence-rate-limit-reached.mdx b/apps/docs/content/troubleshooting/realtime-client-presence-rate-limit-reached.mdx new file mode 100644 index 0000000000000..2d9337e087a5a --- /dev/null +++ b/apps/docs/content/troubleshooting/realtime-client-presence-rate-limit-reached.mdx @@ -0,0 +1,51 @@ +--- +title = "Realtime: ClientPresenceRateLimitReached error" +date_created = "2026-07-30T00:00:00+00:00" +topics = [ "realtime" ] +keywords = [ "presence", "track", "untrack", "rate limit", "ClientPresenceRateLimitReached", "cursor", "high-frequency", "broadcast", "shutdown" ] + +[[errors]] +code = "ClientPresenceRateLimitReached" +message = "Client presence rate limit exceeded" +--- + +If a client sends Presence updates too frequently, you may see this error code in your [Realtime logs](/dashboard/project/_/logs/realtime-logs): + +``` +ClientPresenceRateLimitReached +``` + +On the client side, the channel receives a `system` error message and is then closed. Any Presence, Broadcast, or Postgres Changes subscriptions on that channel stop until the client reconnects. + +This error almost always means Presence is being used for high-frequency updates that it isn't designed for. This guide explains the limit, why it exists, and how to fix it. + +## Why the error occurs + +Each client has a per-connection limit on how often it can send Presence updates. By default, a client can send at most **5 Presence updates within a 30-second window**. Both `track()` and `untrack()` calls count toward this limit. When a client exceeds it, Realtime logs `ClientPresenceRateLimitReached` and shuts the channel down. + +This is a per-client safeguard, and it is separate from the project-wide presence events per second limit (logged as `PresenceRateLimitReached`). A single client can hit `ClientPresenceRateLimitReached` on its own, even when overall project usage is low. + +The limit exists because Presence syncs state through the server and notifies **every** subscriber on the channel on each change. A client that calls `track()` in a tight loop—for example, on every mouse move to share a cursor position—multiplies its updates across all subscribers and degrades the channel for everyone. The rate limit stops one client from doing this. + +## How to fix it + +The fix is to stop sending frequent Presence updates. Choose the option that matches your use case. + +### Use Broadcast for high-frequency updates + +Presence is meant for slow-changing state such as online/offline status, the document a user is viewing, or which page they're on. For high-frequency or fire-and-forget data—live cursors, typing indicators, pointer positions—use [Broadcast](/docs/guides/realtime/broadcast) instead. Broadcast relays messages through Realtime to connected clients without maintaining synced Presence state, so it handles rapid updates without triggering this limit. + +### Throttle your Presence updates + +If you do need Presence for state that changes often, throttle your `track()` and `untrack()` calls so the client sends at most a few Presence updates per window. Only update Presence when the shared state changes, and coalesce bursts into a single update rather than sending one on every event. + +## How to prevent it + +- Reserve Presence for slow-changing state, and reach for [Broadcast](/docs/guides/realtime/broadcast) for anything that updates rapidly. See the guidance in the [Presence guide](/docs/guides/realtime/presence). +- Call `track()` only when the shared state changes, not on a timer or on every input event. + +## Related resources + +- [Presence](/docs/guides/realtime/presence) — when to use Presence and how it works +- [Broadcast](/docs/guides/realtime/broadcast) — the right tool for high-frequency updates +- [Realtime limits](/docs/guides/realtime/limits) — other limits that apply to your project diff --git a/apps/docs/data/errorCodes/authErrorCodes.json b/apps/docs/data/errorCodes/authErrorCodes.json index 4363902d411c4..d6ec6a41e2646 100644 --- a/apps/docs/data/errorCodes/authErrorCodes.json +++ b/apps/docs/data/errorCodes/authErrorCodes.json @@ -30,7 +30,7 @@ "description": "Email sending is not allowed for this address as your project is using the default SMTP service. Emails can only be sent to members in your Supabase organization. If you want to send emails to others, set up a custom SMTP provider.", "references": [ { - "href": "https://supabase.com/docs/guides/auth/auth-smtp", + "href": "/docs/guides/auth/auth-smtp", "description": "Setting up a custom SMTP provider" } ] @@ -75,7 +75,7 @@ "description": "To call this API, the user must have a higher Authenticator Assurance Level. To resolve, ask the user to solve an MFA challenge.", "references": [ { - "href": "https://supabase.com/docs/guides/auth/auth-mfa", + "href": "/docs/guides/auth/auth-mfa", "description": "MFA" } ] @@ -120,7 +120,7 @@ "description": "Further MFA verification is rejected. Only returned if the MFA verification attempt hook returns a reject decision.", "references": [ { - "href": "https://supabase.com/docs/guides/auth/auth-hooks/mfa-verification-hook", + "href": "/docs/guides/auth/auth-hooks/mfa-verification-hook", "description": "MFA verification hook" } ] @@ -192,7 +192,7 @@ "description": "Refresh token has been revoked and falls outside the refresh token reuse interval. See the documentation on sessions for further information.", "references": [ { - "href": "https://supabase.com/docs/guides/auth/sessions", + "href": "/docs/guides/auth/sessions", "description": "Auth sessions" } ] @@ -225,7 +225,7 @@ "description": "Using Enterprise SSO with SAML 2.0 is not enabled on the Auth server.", "references": [ { - "href": "https://supabase.com/docs/guides/auth/enterprise-sso/auth-sso-saml", + "href": "/docs/guides/auth/enterprise-sso/auth-sso-saml", "description": "Enterprise SSO" } ] @@ -240,7 +240,7 @@ "description": "Session to which the API request relates has expired. This can occur if an inactivity timeout is configured, or the session entry has exceeded the configured timebox value. See the documentation on sessions for more information.", "references": [ { - "href": "https://supabase.com/docs/guides/auth/sessions", + "href": "/docs/guides/auth/sessions", "description": "Auth sessions" } ] diff --git a/apps/docs/data/errorCodes/realtimeErrorCodes.json b/apps/docs/data/errorCodes/realtimeErrorCodes.json index 3d39e335df8fd..efe6c1baf931e 100644 --- a/apps/docs/data/errorCodes/realtimeErrorCodes.json +++ b/apps/docs/data/errorCodes/realtimeErrorCodes.json @@ -2,6 +2,9 @@ "TopicNameRequired": { "description": "You are trying to use Realtime without a topic name set." }, + "InvalidJoinPayload": { + "description": "The payload provided to Realtime on connect is invalid." + }, "RealtimeDisabledForConfiguration": { "description": "The configuration provided to Realtime on connect will not be able to provide you any Postgres Changes.", "resolution": "Verify your configuration on channel startup as you might not have your tables properly registered." @@ -10,18 +13,13 @@ "description": "The tenant you are trying to connect to does not exist.", "resolution": "Verify the tenant name you are trying to connect to exists in the realtime.tenants table." }, + "MissingAPIKey": { + "description": "No API key was provided in the `x-api-key` header or `apikey` query parameter." + }, "ErrorConnectingToWebsocket": { "description": "Error when trying to connect to the WebSocket server.", "resolution": "Verify user information on connect." }, - "ErrorAuthorizingWebsocket": { - "description": "Error when trying to authorize the WebSocket connection.", - "resolution": "Verify user information on connect." - }, - "TableHasSpacesInName": { - "description": "The table you are trying to listen to has spaces in its name which we are unable to support.", - "resolution": "Change the table name to not have spaces in it." - }, "UnableToDeleteTenant": { "description": "Error when trying to delete a tenant." }, @@ -46,12 +44,18 @@ "ClientJoinRateLimitReached": { "description": "The rate of joins per second from your clients has reached the channel limits." }, + "DatabaseConnectionRateLimitReached": { + "description": "The rate of attempts to connect to the database has reached the limit." + }, + "MessagePerSecondRateLimitReached": { + "description": "The rate of messages per second from your clients has reached the channel limits." + }, "RealtimeDisabledForTenant": { "description": "Realtime has been disabled for the tenant.", "resolution": "Your project may have been suspended for exceeding usage quotas. Contact support with your project reference ID and a description of your Realtime use case.", "references": [ { - "href": "https://supabase.com/docs/guides/troubleshooting/realtime-project-suspended-for-exceeding-quotas", + "href": "/docs/guides/troubleshooting/realtime-project-suspended-for-exceeding-quotas", "description": "Troubleshooting guide for suspended projects" } ] @@ -64,7 +68,7 @@ "resolution": "Verify your database connection limits.", "references": [ { - "href": "https://supabase.com/docs/guides/database/connection-management", + "href": "/docs/guides/database/connection-management", "description": "Connection management guide" } ] @@ -75,29 +79,32 @@ "MigrationsFailedToRun": { "description": "Error when running the migrations against the Tenant database that are required by Realtime." }, - "StartListenAndReplicationFailed": { + "StartReplicationFailed": { "description": "Error when starting the replication and listening of errors for database broadcasting." }, + "ReplicationConnectionTimeout": { + "description": "Replication connection timed out during initialization." + }, + "ReplicationConnectionDown": { + "description": "The replication connection was terminated and a recovery window has been opened." + }, + "ReplicationConnectionRecoveryFailed": { + "description": "The database check failed while trying to recover the replication connection." + }, "ReplicationMaxWalSendersReached": { "description": "Maximum number of WAL senders reached in tenant database.", "references": [ { - "href": "https://supabase.com/docs/guides/database/custom-postgres-config#cli-configurable-settings", + "href": "/docs/guides/database/custom-postgres-config#cli-configurable-settings", "description": "Configuring max WAL senders" } ] }, - "MigrationCheckFailed": { - "description": "Check to see if we require to run migrations fails." - }, "PartitionCreationFailed": { "description": "Error when creating partitions for realtime.messages." }, - "ErrorStartingPostgresCDCStream": { - "description": "Error when starting the Postgres CDC stream which is used for Postgres Changes." - }, - "UnknownDataProcessed": { - "description": "An unknown data type was processed by the Realtime system." + "MissingPartition": { + "description": "Realtime was unable to find the expected messages partition." }, "ErrorStartingPostgresCDC": { "description": "Error when starting the Postgres CDC extension which is used for Postgres Changes." @@ -111,29 +118,29 @@ "PoolingReplicationError": { "description": "Error when pooling the replication slot." }, - "SubscriptionDeletionFailed": { - "description": "Error when trying to delete a subscription for postgres changes." + "CheckOidsError": { + "description": "Error when fetching the publication tables (OIDs) during the periodic check; the existing OIDs, replication slot and subscribers are left untouched." }, - "UnableToDeletePhantomSubscriptions": { - "description": "Error when trying to delete subscriptions that are no longer being used." + "SubscriptionCleanupFailed": { + "description": "Error when trying to clean up all subscriptions on subscription manager initialization or OID change." }, - "UnableToCheckProcessesOnRemoteNode": { - "description": "Error when trying to check the processes on a remote node." + "SubscriptionDeletionFailed": { + "description": "Error when trying to delete a subscription for postgres changes." }, - "UnableToCreateCounter": { - "description": "Error when trying to create a counter to track rate limits for a tenant." + "ReplicationPollerConnectionFailed": { + "description": "Error when the replication poller process fails to connect to the database on startup." }, - "UnableToIncrementCounter": { - "description": "Error when trying to increment a counter to track rate limits for a tenant." + "ReplicationPollerMaxRetriesReached": { + "description": "The replication poller gave up after the maximum number of consecutive retries and stopped the tenant's Postgres Changes workers." }, - "UnableToDecrementCounter": { - "description": "Error when trying to decrement a counter to track rate limits for a tenant." + "DropReplicationSlotFailed": { + "description": "Error when dropping the replication slot after the publication became empty; the poller stops so the temporary slot is released with the connection." }, - "UnableToUpdateCounter": { - "description": "Error when trying to update a counter to track rate limits for a tenant." + "SubscriptionManagerConnectionFailed": { + "description": "Error when the subscription manager process fails to connect to the database on startup." }, - "UnableToFindCounter": { - "description": "Error when trying to find a counter to track rate limits for a tenant." + "UnableToCheckProcessesOnRemoteNode": { + "description": "Error when trying to check the processes on a remote node." }, "UnhandledProcessMessage": { "description": "Unhandled message received by a Realtime process." @@ -147,21 +154,15 @@ "IncreaseConnectionPool": { "description": "The number of connections you have set for Realtime are not enough to handle your current use case." }, + "IncreaseSubscriptionConnectionPool": { + "description": "The subscription connection pool hit too many database timeouts and should be increased." + }, "RlsPolicyError": { "description": "Error on RLS policy used for authorization." }, - "ConnectionInitializing": { - "description": "Database is initializing connection." - }, - "DatabaseConnectionIssue": { - "description": "Database had connection issues and connection was not able to be established." - }, "UnableToConnectToProject": { "description": "Unable to connect to Project database." }, - "InvalidJWTExpiration": { - "description": "JWT exp claim value it's incorrect." - }, "JwtSignatureError": { "description": "JWT signature was not able to be validated." }, @@ -174,11 +175,8 @@ "RealtimeRestarting": { "description": "Realtime is currently restarting." }, - "UnableToProcessListenPayload": { - "description": "Payload sent in NOTIFY operation was not JSON parsable." - }, - "UnableToListenToTenantDatabase": { - "description": "Unable to LISTEN for notifications against the Tenant Database." + "InvalidPresencePayload": { + "description": "Payload from track event sent to Presence isn't a map." }, "UnprocessableEntity": { "description": "Received a HTTP request with a body that was not able to be processed by the endpoint." @@ -192,6 +190,9 @@ "ErrorOnRpcCall": { "description": "Error when calling another realtime node." }, + "RpcError": { + "description": "Error returned when calling another realtime node over RPC." + }, "ErrorExecutingTransaction": { "description": "Error executing a database transaction in tenant database." }, @@ -204,10 +205,98 @@ "UnableToEncodeJson": { "description": "An error were we are not handling correctly the response to be sent to the end user." }, + "UnableToBroadcastChanges": { + "description": "Error when trying to broadcast database changes (realtime.messages) to subscribers." + }, + "WarnSendingBroadcastMessage": { + "description": "Warning when `realtime.send` or `realtime.send_binary` cannot insert the message.", + "references": [ + { + "href": "/docs/guides/realtime/troubleshooting", + "description": "Realtime troubleshooting guide" + } + ] + }, + "UnexpectedMessageReceived": { + "description": "An unexpected message was received by the replication connection process." + }, + "ErrorRunningQuery": { + "description": "Error when running a query against the tenant database." + }, + "QueryCanceled": { + "description": "A database query was canceled, usually due to a statement timeout." + }, + "UnknownError": { + "description": "An unhandled error occurred." + }, "UnknownErrorOnController": { "description": "An error we are not handling correctly was triggered on a controller." }, "UnknownErrorOnChannel": { "description": "An error we are not handling correctly was triggered on a channel." + }, + "PresenceRateLimitReached": { + "description": "Limit of presence events reached globally." + }, + "ClientPresenceRateLimitReached": { + "description": "A single client sent Presence updates too frequently and had its channel closed. This usually means Presence is being used for high-frequency updates it is not designed for.", + "resolution": "Reserve Presence for slow-changing state and use Broadcast for high-frequency updates such as live cursors, or throttle your track() calls.", + "references": [ + { + "href": "/docs/guides/troubleshooting/realtime-client-presence-rate-limit-reached", + "description": "Troubleshooting guide for the ClientPresenceRateLimitReached error" + } + ] + }, + "UnableToReplayMessages": { + "description": "An error while replaying messages." + }, + "JwtSignerError": { + "description": "Failed to generate a JWT signer — check your JWT secret or JWKS configuration." + }, + "MalformedWebSocketMessage": { + "description": "Received a WebSocket message that is empty, invalid JSON, or missing required fields (`ref`, `topic`, or `event`). The connection is kept alive but the message is dropped." + }, + "UnknownErrorOnWebSocketMessage": { + "description": "An unexpected error occurred while processing an incoming WebSocket message. The connection is kept alive but the message is dropped." + }, + "ReplicationSlotLagTooHigh": { + "description": "The replication slot WAL lag has exceeded 50% of `max_slot_wal_keep_size`. The replication connection is shut down and will be restarted to prevent the slot from being invalidated by PostgreSQL." + }, + "ReplicationSlotLagCheckSkipped": { + "description": "The periodic replication slot lag check could not be completed, typically because the tenant database connection was unavailable. The check is skipped and retried on the next watchdog interval." + }, + "HttpServerError": { + "description": "Phoenix converted an unhandled exception into a 5xx HTTP response. The log includes the underlying error and status to explain a server error that request metrics alone would not surface." + }, + "HttpClientError": { + "description": "Phoenix converted an exception into a 4xx HTTP response (for example a request to an unknown route). The log includes the underlying error and status." + }, + "JoinsRateLimitReached": { + "description": "The rate of joins per second from your clients has reached the limit and the connection was refused." + }, + "InvalidJWTToken": { + "description": "The JWT provided on connect is expired or is missing required claims (`role` and `exp`)." + }, + "PrivateOnly": { + "description": "The connection was rejected because this project only allows private channels." + }, + "UnableToHandleBroadcast": { + "description": "Error when handling a broadcast message." + }, + "UnableToHandlePresence": { + "description": "Error when handling a presence message on a channel." + }, + "ChannelShutdown": { + "description": "The channel was shut down and an error system message was pushed to the client." + }, + "ReplicationRecoveryWindowExceeded": { + "description": "The replication connection recovery window was exceeded and the connection was terminated." + }, + "MigrationCountMismatch": { + "description": "The cached `migrations_ran` count did not match the tenant database and is being reconciled." + }, + "MigrationCountMismatchReconcileFailed": { + "description": "Failed to reconcile the `migrations_ran` count mismatch between the cache and the tenant database." } } diff --git a/apps/docs/docs/ref/csharp/release-notes.mdx b/apps/docs/docs/ref/csharp/release-notes.mdx index 751fc13c4245d..7022ba9aae50b 100644 --- a/apps/docs/docs/ref/csharp/release-notes.mdx +++ b/apps/docs/docs/ref/csharp/release-notes.mdx @@ -3,6 +3,128 @@ id: release-notes title: Release Notes --- +## 1.5.0 - 2026-07-30 + +- Update dependency: `Supabase.Realtime@7.3.1` + - Fix `channel.Send()` hanging on unacknowledged broadcast pushes ([#72](https://github.com/supabase-community/realtime-csharp/issues/72)). +- Update dependency: `Supabase.Storage@2.6.0` + - Add a `CancellationToken` to the `Download` methods ([#49](https://github.com/supabase-community/storage-csharp/issues/49)). + - Implement cache purge ([#50](https://github.com/supabase-community/storage-csharp/issues/50)). + - Non-JSON Storage errors now throw a `SupabaseStorageException` ([#46](https://github.com/supabase-community/storage-csharp/issues/46)). + - Fix a trailing `?` being left on `CreateSignedUrl` results ([#51](https://github.com/supabase-community/storage-csharp/issues/51)). + +## 1.4.0 - 2026-07-23 + +This is the observability release: every child library now emits diagnostics through `System.Diagnostics`, making the SDK compatible with OpenTelemetry. + +- Expose aggregated telemetry source names for OpenTelemetry ([#285](https://github.com/supabase-community/supabase-csharp/issues/285)). +- Update dependency: `Supabase.Core@1.2.0` + - Add OpenTelemetry-compatible diagnostics primitives ([#6](https://github.com/supabase-community/core-csharp/issues/6)). +- Update dependency: `Supabase.Gotrue@6.2.0` + - Emit observability via `System.Diagnostics` and deprecate the debug callback ([#140](https://github.com/supabase-community/gotrue-csharp/issues/140)). +- Update dependency: `Supabase.Postgrest@4.4.0` + - Emit observability via `System.Diagnostics` and deprecate the debug callback ([#136](https://github.com/supabase-community/postgrest-csharp/issues/136)). +- Update dependency: `Supabase.Storage@2.5.0` + - Emit observability via `System.Diagnostics` ([#43](https://github.com/supabase-community/storage-csharp/issues/43)). +- Update dependency: `Supabase.Functions@2.2.0` + - Emit observability via `System.Diagnostics` ([#15](https://github.com/supabase-community/functions-csharp/issues/15)). + +## 1.3.0 - 2026-07-20 + +- Wire Realtime's Postgrest client automatically so models received from `postgres_changes` support `Update()` and `Delete()` ([#282](https://github.com/supabase-community/supabase-csharp/issues/282)). +- Update dependency: `Supabase.Postgrest@4.3.0` + - Add `Client.Attach()` to populate a model's client context for `Update`/`Delete` ([#135](https://github.com/supabase-community/postgrest-csharp/issues/135)). + - Add `ClientOptions.SerializeEnumsAsStrings` to opt into string enum serialization ([#134](https://github.com/supabase-community/postgrest-csharp/issues/134)). + - Fix: exclude reference columns from update and delete select queries ([#132](https://github.com/supabase-community/postgrest-csharp/issues/132)). +- Update dependency: `Supabase.Realtime@7.3.0` + - Attach the Postgrest client context to models returned by `PostgresChangesResponse` ([#70](https://github.com/supabase-community/realtime-csharp/issues/70)). + +## 1.2.0 - 2026-07-16 + +- Lower the `Newtonsoft.Json` minimum version to `13.0.2` across all packages to ease dependency resolution ([#275](https://github.com/supabase-community/supabase-csharp/issues/275)). +- Update dependency: `Supabase.Gotrue@6.1.0` + - Add an option for setting `redirect_url` on MagicLink sign-in. + - Add `state` parameter support to OAuth provider sign-in. + - Expose `RefreshToken(accessToken, refreshToken)` on `IGotrueClient`. + - Fix: correct the PKCE verifier/challenge swap in `SignInWithOtp` and `ResetPasswordForEmail`. + - Fix: classify refresh-token rejections coming from current gotrue. +- Update dependency: `Supabase.Postgrest@4.2.0` + - Fix: null-reference crash when a `Where` predicate null-checks a captured value ([#122](https://github.com/supabase-community/postgrest-csharp/issues/122)). + - Fix: preserve `DateTime` kind, precision, and wall-clock across read and write ([#123](https://github.com/supabase-community/postgrest-csharp/issues/123)). +- Update dependency: `Supabase.Storage@2.4.2` + - Add resumable uploads ([#29](https://github.com/supabase-community/storage-csharp/issues/29)). + - Add `CancellationToken` support to upload methods ([#30](https://github.com/supabase-community/storage-csharp/issues/30)). + - In-memory caching for resumable uploads ([#35](https://github.com/supabase-community/storage-csharp/issues/35)). +- Update dependency: `Supabase.Core@1.1.0` + - Add structured `X-Client-Info` header metadata ([#2](https://github.com/supabase-community/core-csharp/issues/2)). +- Update dependencies: `Supabase.Realtime@7.2.1`, `Supabase.Functions@2.1.1` (maintenance). + +## 1.1.2 - 2025-07-07 + +- Update dependency: `Supabase.Realtime@7.2.0` + - Implement Postgres change filters ([#55](https://github.com/supabase-community/realtime-csharp/pull/55)). + - Fix: `SerializerSettings` were not being passed to `PostgresChangesResponse`. + - Fix: use a compatible websocket library for Blazor WASM. +- Update dependency: `Supabase.Postgrest@4.1.0` + - Add `count` to `ModeledResponse` ([#103](https://github.com/supabase-community/postgrest-csharp/pull/103)). + - Add support for `long`, `DateTime`, and `DateTimeOffset` criteria in filter expressions ([#101](https://github.com/supabase-community/postgrest-csharp/pull/101)). + +## 1.1.1 - 2024-07-27 + +- Support for passing Headers specified in `ClientOptions` to the `Supabase.Realtime` Client. +- Update dependency: `Supabase.Gotrue@6.0.3` + - Add admin calls for MFA ([#105](https://github.com/supabase-community/gotrue-csharp/pull/105)). Big thanks to [@michaelschattgen](https://github.com/michaelschattgen). +- Update dependency: `Supabase.Realtime@7.0.2` + - Updates dependency: `Websocket.Client@5.1.2`. + - Updates dependency: `Supabase.Postgrest@4.0.3`. + - Adds support for specifying `GetHeaders` on the `RealtimeClient`, which are included on the initial request to establish the websocket connection ([#167](https://github.com/supabase-community/supabase-csharp/issues/167)). + +## 1.1.0 - 2024-07-25 + +- Supports passing Headers specified in `ClientOptions` to child APIs. +- Drop support for `netstandard2.0` — `Supabase` now targets `netstandard2.1`. +- Update dependency: `Supabase.Gotrue@6.0.2` + - Add support for MFA signup and login flows ([#103](https://github.com/supabase-community/gotrue-csharp/pull/103)). Huge thanks to [@michaelschattgen](https://github.com/michaelschattgen). + - Add `ExchangeCodeForSession` to `StatelessClient` ([#102](https://github.com/supabase-community/gotrue-csharp/pull/102)). Thanks [@alexbakker](https://github.com/alexbakker). + - Major: change target framework to `netstandard2.1`; use a CSPRNG to generate the code verifier ([#99](https://github.com/supabase-community/gotrue-csharp/pull/99)). Thanks [@alexbakker](https://github.com/alexbakker). + - Ban user functionality ([#101](https://github.com/supabase-community/gotrue-csharp/pull/101)). Thanks [@celestebyte](https://github.com/celestebyte). + +## 1.0.5 - 2024-06-29 + +- Update dependency: `Supabase.Storage@2.0.2` +- Update dependency: `Supabase.Gotrue@5.0.6` + - Introduces `VerifyTokenHash` to support the PKCE flow for email signup ([#98](https://github.com/supabase-community/gotrue-csharp/pull/98)). Thanks [@alexbakker](https://github.com/alexbakker). + +## 1.0.4 - 2024-06-11 + +- Update dependency: `Supabase.Gotrue@5.0.5` + - Allow for scoped `SignOut`. Thanks [@AndrewKahr](https://github.com/AndrewKahr). + - Various minor SSO fixes. Thanks [@Rycko1](https://github.com/Rycko1). + - Implement `SignInWithSSO`. Huge thank you to [@Rycko1](https://github.com/Rycko1). +- Update dependency: `Supabase.Postgrest@4.0.3` + - Fix set null value on string property ([#97](https://github.com/supabase-community/postgrest-csharp/pull/97)). Thanks [@alustrement-bob](https://github.com/alustrement-bob). + +## 1.0.3 - 2024-05-22 + +- Update dependency: `Supabase.Gotrue@5.0.2` + - Add missing properties (`ProviderRefreshToken` and `ProviderToken`) to the `Session` object to reflect the current state of `auth-js`. +- Update dependency: `Supabase.Realtime@7.0.1` + - Return a `Task` from the `Track` and `Untrack` methods ([#47](https://github.com/supabase-community/realtime-csharp/issues/47)). + +## 1.0.2 - 2024-05-16 + +- Update dependency: `Supabase.Postgrest@4.0.2` + - Set `ConfigureAwait(false)` on the response to prevent deadlocking applications ([#96](https://github.com/supabase-community/postgrest-csharp/pull/96)). Thanks [@pur3extreme](https://github.com/pur3extreme). +- Update dependency: `Supabase.Gotrue@5.0.1` + - Set `ConfigureAwait(false)` on the response to prevent deadlocking applications. +- Update dependency: `Supabase.Storage@2.0.1` + - Fix `CreateSignedUrl` with `TransformOptions` ([#15](https://github.com/supabase-community/storage-csharp/issues/15), [#16](https://github.com/supabase-community/storage-csharp/pull/16)). Thanks [@alustrement-bob](https://github.com/alustrement-bob). + +## 1.0.1 - 2024-05-07 + +- Update dependency: `Supabase.Postgrest@4.0.1` + - Changes the `IPostgrestTable<>` contract to return the interface rather than a concrete type ([#92](https://github.com/supabase-community/postgrest-csharp/issues/92)). + ## 1.0.0 - 2024-04-21 - Assembly Name has been changed to `Supabase.dll` diff --git a/apps/docs/features/ui/ErrorCodes.tsx b/apps/docs/features/ui/ErrorCodes.tsx index f003d78eab305..05eddd502c621 100644 --- a/apps/docs/features/ui/ErrorCodes.tsx +++ b/apps/docs/features/ui/ErrorCodes.tsx @@ -1,7 +1,7 @@ import _authErrorCodes from '~/data/errorCodes/authErrorCodes.json' import _realtimeErrorCodes from '~/data/errorCodes/realtimeErrorCodes.json' +import { MdxAnchor } from '~/features/docs/MdxAnchor' import { type ErrorCodeDefinition } from '~/resources/error/errorTypes' -import Link from 'next/link' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui' const errorCodesByService = { @@ -41,7 +41,7 @@ export function ErrorCodes({ service }: ErrorCodesProps) { diff --git a/apps/docs/spec/supabase_csharp_v1.yml b/apps/docs/spec/supabase_csharp_v1.yml index 538710b48be8f..a5b868a0c6aff 100644 --- a/apps/docs/spec/supabase_csharp_v1.yml +++ b/apps/docs/spec/supabase_csharp_v1.yml @@ -13,7 +13,7 @@ info: libraries: - name: 'C#' id: 'csharp' - version: '1.0.0' + version: '1.5.0' functions: - id: initializing @@ -93,26 +93,23 @@ functions: } } - void Initialize() - { - // Get All Messages - var response = await client.Table().Get(); - List models = response.Models; + // Get All Messages + var response = await supabase.From().Get(); + List models = response.Models; - // Insert - var newMessage = new Message { UserName = "acupofjose", ChannelId = 1 }; - await client.Table().Insert(); + // Insert + var newMessage = new Message { UserName = "acupofjose", ChannelId = 1 }; + await supabase.From().Insert(newMessage); - // Update - var model = response.Models.First(); - model.UserName = "elrhomariyounes"; - await model.Update(); + // Update + var model = response.Models.First(); + model.UserName = "elrhomariyounes"; + await model.Update(); - // Delete - await response.Models.Last().Delete(); + // Delete + await response.Models.Last().Delete(); - // etc. - } + // etc. ``` - id: sign-up title: 'SignUp()' @@ -226,6 +223,14 @@ functions: ```c# await supabase.Auth.SignOut(); ``` + - id: sign-out-with-scope + name: Sign out with a scope + description: | + By default, `SignOut()` uses the `Global` scope, which revokes every session for the user. Pass `SignOutScope.Local` to sign out only the current session, or `SignOutScope.Others` to keep the current session and revoke all the rest. + code: | + ```c# + await supabase.Auth.SignOut(SignOutScope.Local); + ``` - id: verify-otp title: 'VerifyOtp()' notes: | @@ -328,7 +333,7 @@ functions: } }); ``` - - id: auth-reset-password-for-email + - id: reset-password-for-email title: 'ResetPasswordForEmail()' description: | Sends a reset request to an email address. @@ -337,12 +342,461 @@ functions: examples: - id: reset-password - name: Reset password for Flutter + name: Reset password for email isSpotlight: true code: | ```c# await supabase.Auth.ResetPasswordForEmail("joseph@supabase.io"); ``` + - id: sign-in-anonymously + title: 'SignInAnonymously()' + description: | + Creates a new anonymous user. + notes: | + - Returns an anonymous user with a session. The user's `IsAnonymous` claim is set to `true`. + - You can later convert an anonymous user into a permanent one by calling [`UpdateUser()`](/docs/reference/csharp/update-user) with an email or phone number, or by linking an OAuth identity with [`LinkIdentity()`](/docs/reference/csharp/link-identity). + - Enable anonymous sign-ins in [your project's auth settings](https://supabase.com/dashboard/project/_/settings/auth). + examples: + - id: create-an-anonymous-user + name: Create an anonymous user + isSpotlight: true + code: | + ```c# + var session = await supabase.Auth.SignInAnonymously(); + ``` + - id: create-an-anonymous-user-with-metadata + name: With user metadata + code: | + ```c# + var options = new SignInAnonymouslyOptions + { + Data = new Dictionary { { "display_name", "Anonymous" } } + }; + + var session = await supabase.Auth.SignInAnonymously(options); + ``` + + - id: sign-in-with-id-token + title: 'SignInWithIdToken()' + description: | + Signs in a user using an ID token issued by a supported OIDC provider. + notes: | + - The ID token is verified for validity before a session is established. + - Supported providers are `Provider.Google`, `Provider.Apple`, `Provider.Azure`, and `Provider.Facebook`. + - If the ID token contains an `at_hash` claim, pass the matching `accessToken`. If it contains a `nonce` claim, pass the `nonce` used to obtain the token. + examples: + - id: sign-in-with-google-id-token + name: Sign in with a Google ID token + isSpotlight: true + code: | + ```c# + var session = await supabase.Auth.SignInWithIdToken(Provider.Google, idToken); + ``` + - id: sign-in-with-nonce + name: With a nonce + code: | + ```c# + var session = await supabase.Auth.SignInWithIdToken(Provider.Apple, idToken, nonce: nonce); + ``` + + - id: sign-in-with-sso + title: 'SignInWithSSO()' + description: | + Signs in a user through enterprise single sign-on (SSO). + notes: | + - Before you can use SSO, register your identity provider with the [Supabase CLI](https://supabase.com/docs/guides/auth/enterprise-sso/auth-sso-saml). + - You can sign in either by email domain or by the provider's ID (a `Guid`). + - The call returns a URL. Redirect the user to it to complete sign-in with their identity provider. + examples: + - id: sign-in-with-email-domain + name: Sign in with an email domain + isSpotlight: true + code: | + ```c# + var response = await supabase.Auth.SignInWithSSO("acme.com"); + + // Redirect the user to complete sign-in. + var ssoUrl = response.Uri; + ``` + - id: sign-in-with-provider-id + name: Sign in with a provider ID + code: | + ```c# + var response = await supabase.Auth.SignInWithSSO(providerId); + var ssoUrl = response.Uri; + ``` + + - id: refresh-session + title: 'RefreshSession()' + description: | + Refreshes the current session and returns the new session data. + notes: | + - Requires a signed-in user. + - The SDK refreshes tokens automatically in the background. Call this only when you need to force a refresh. + examples: + - id: refresh-the-session + name: Refresh the session + isSpotlight: true + code: | + ```c# + var session = await supabase.Auth.RefreshSession(); + ``` + + - id: link-identity + title: 'LinkIdentity()' + description: | + Links an OAuth identity to the signed-in user. + notes: | + - Requires a signed-in user, and uses the PKCE flow. + - Enable [manual linking](https://supabase.com/docs/guides/auth/auth-identity-linking#manual-linking-beta) in your project before using this method. + - The call returns a URL. Redirect the user to it to authorize the new identity. + examples: + - id: link-an-identity + name: Link an identity + isSpotlight: true + code: | + ```c# + var state = await supabase.Auth.LinkIdentity(Provider.Github, new SignInOptions()); + + // Redirect the user to authorize the linked provider. + var authorizeUrl = state.Uri; + ``` + + - id: unlink-identity + title: 'UnlinkIdentity()' + description: | + Unlinks an identity from the signed-in user. + notes: | + - Requires a signed-in user with more than one linked identity. + - Once unlinked, the user can no longer sign in with that identity. + - Retrieve the user's identities from `supabase.Auth.CurrentUser.Identities`. + examples: + - id: unlink-an-identity + name: Unlink an identity + isSpotlight: true + code: | + ```c# + var identity = supabase.Auth.CurrentUser.Identities.First(); + await supabase.Auth.UnlinkIdentity(identity); + ``` + + - id: exchange-code-for-session + title: 'ExchangeCodeForSession()' + description: | + Exchanges an auth code for a session as part of the PKCE flow. + notes: | + - Used to complete a PKCE sign-in flow (for example after an OAuth redirect or a password reset). + - Pass the code verifier you generated at the start of the flow along with the auth code returned in the redirect. + examples: + - id: exchange-an-auth-code + name: Exchange an auth code + isSpotlight: true + code: | + ```c# + var session = await supabase.Auth.ExchangeCodeForSession(codeVerifier, authCode); + ``` + + - id: send-password-reauthentication + title: 'Reauthenticate()' + description: | + Sends a reauthentication nonce to the signed-in user's email or phone number. + notes: | + - Requires a signed-in user. + - Use this before updating a password when [secure password change](https://supabase.com/docs/guides/auth/passwords#secure-password-change) is enabled. Pass the nonce the user receives to [`UpdateUser()`](/docs/reference/csharp/update-user). + examples: + - id: send-a-reauthentication-nonce + name: Send a reauthentication nonce + isSpotlight: true + code: | + ```c# + await supabase.Auth.Reauthenticate(); + ``` + + - id: mfa-enroll + title: 'Enroll()' + description: | + Starts the enrollment process for a new multi-factor authentication (MFA) factor. + notes: | + - Creates a new `unverified` factor. Present the returned QR code or secret to the user, then verify it with [`Verify()`](/docs/reference/csharp/mfa-verify) or [`ChallengeAndVerify()`](/docs/reference/csharp/mfa-challenge-and-verify). + - Only Time-based One-Time Password (TOTP) factors are supported. Set `FactorType` to `"totp"`. + examples: + - id: enroll-a-factor + name: Enroll a factor + isSpotlight: true + code: | + ```c# + var response = await supabase.Auth.Enroll(new MfaEnrollParams + { + FactorType = "totp", + FriendlyName = "My Authenticator App" + }); + + // Present the QR code to the user so they can add it to their authenticator app. + var qrCode = response.Totp.QrCode; + ``` + + - id: mfa-challenge + title: 'Challenge()' + description: | + Creates a challenge for an enrolled MFA factor. + notes: | + - Pair the returned challenge with [`Verify()`](/docs/reference/csharp/mfa-verify) to complete verification. + - Use [`ChallengeAndVerify()`](/docs/reference/csharp/mfa-challenge-and-verify) to create and verify a challenge in a single call. + examples: + - id: create-a-challenge + name: Create a challenge + isSpotlight: true + code: | + ```c# + var response = await supabase.Auth.Challenge(new MfaChallengeParams + { + FactorId = factorId + }); + ``` + + - id: mfa-verify + title: 'Verify()' + description: | + Verifies a code against an MFA challenge. + notes: | + - The code is the one the user reads from their authenticator app. + - On success, the session's assurance level is promoted to `aal2`. + examples: + - id: verify-a-challenge + name: Verify a challenge + isSpotlight: true + code: | + ```c# + var session = await supabase.Auth.Verify(new MfaVerifyParams + { + FactorId = factorId, + ChallengeId = challengeId, + Code = "123456" + }); + ``` + + - id: mfa-challenge-and-verify + title: 'ChallengeAndVerify()' + description: | + Creates a challenge and immediately verifies it with the given code. + notes: | + - A convenience method that combines [`Challenge()`](/docs/reference/csharp/mfa-challenge) and [`Verify()`](/docs/reference/csharp/mfa-verify). + - The code is the one the user reads from their authenticator app. + examples: + - id: create-and-verify-a-challenge + name: Create and verify a challenge + isSpotlight: true + code: | + ```c# + var session = await supabase.Auth.ChallengeAndVerify(new MfaChallengeAndVerifyParams + { + FactorId = factorId, + Code = "123456" + }); + ``` + + - id: mfa-unenroll + title: 'Unenroll()' + description: | + Removes an MFA factor from the signed-in user. + notes: | + - Unenrolling a `verified` factor requires an `aal2` session. + examples: + - id: unenroll-a-factor + name: Unenroll a factor + isSpotlight: true + code: | + ```c# + await supabase.Auth.Unenroll(new MfaUnenrollParams + { + FactorId = factorId + }); + ``` + + - id: mfa-get-authenticator-assurance-level + title: 'GetAuthenticatorAssuranceLevel()' + description: | + Returns the Authenticator Assurance Level (AAL) for the active session. + notes: | + - `aal1` (or `null`) means the user signed in with a single factor (password, OTP, magic link, or social login). + - `aal2` means the user also verified an MFA factor. + - Use this to decide whether to prompt the user to complete an MFA challenge. + examples: + - id: get-the-assurance-level + name: Get the assurance level + isSpotlight: true + code: | + ```c# + var response = await supabase.Auth.GetAuthenticatorAssuranceLevel(); + ``` + + - id: get-user-by-id + title: 'AdminAuth().GetUserById()' + description: | + Retrieves a user by their ID. + notes: | + - This is an admin method. Call it with your service role key, and only from a trusted server environment—never from a client app. + examples: + - id: retrieve-a-user + name: Retrieve a user + isSpotlight: true + code: | + ```c# + var admin = supabase.AdminAuth(SUPABASE_SERVICE_KEY); + var user = await admin.GetUserById(userId); + ``` + + - id: list-users + title: 'AdminAuth().ListUsers()' + description: | + Retrieves a list of users. + notes: | + - This is an admin method. Call it with your service role key, and only from a trusted server environment—never from a client app. + - Supports filtering, sorting, and pagination. + examples: + - id: list-all-users + name: List all users + isSpotlight: true + code: | + ```c# + var admin = supabase.AdminAuth(SUPABASE_SERVICE_KEY); + var users = await admin.ListUsers(); + ``` + - id: paginate-users + name: Paginate users + code: | + ```c# + var admin = supabase.AdminAuth(SUPABASE_SERVICE_KEY); + var users = await admin.ListUsers(page: 1, perPage: 20); + ``` + + - id: create-user + title: 'AdminAuth().CreateUser()' + description: | + Creates a user. + notes: | + - This is an admin method. Call it with your service role key, and only from a trusted server environment—never from a client app. + - To send an invite link instead of setting a password directly, use [`InviteUserByEmail()`](/docs/reference/csharp/invite-user-by-email). + examples: + - id: create-a-user + name: Create a user + isSpotlight: true + code: | + ```c# + var admin = supabase.AdminAuth(SUPABASE_SERVICE_KEY); + var user = await admin.CreateUser("user@example.com", "password", new AdminUserAttributes + { + EmailConfirm = true + }); + ``` + + - id: delete-user + title: 'AdminAuth().DeleteUser()' + description: | + Deletes a user by their ID. + notes: | + - This is an admin method. Call it with your service role key, and only from a trusted server environment—never from a client app. + examples: + - id: delete-a-user + name: Delete a user + isSpotlight: true + code: | + ```c# + var admin = supabase.AdminAuth(SUPABASE_SERVICE_KEY); + await admin.DeleteUser(userId); + ``` + + - id: invite-user-by-email + title: 'AdminAuth().InviteUserByEmail()' + description: | + Sends an invite link to an email address. + notes: | + - This is an admin method. Call it with your service role key, and only from a trusted server environment—never from a client app. + examples: + - id: invite-a-user + name: Invite a user + isSpotlight: true + code: | + ```c# + var admin = supabase.AdminAuth(SUPABASE_SERVICE_KEY); + await admin.InviteUserByEmail("user@example.com"); + ``` + + - id: generate-link + title: 'AdminAuth().GenerateLink()' + description: | + Generates an email link for signup, invite, magic link, recovery, or email change flows. + notes: | + - This is an admin method. Call it with your service role key, and only from a trusted server environment—never from a client app. + - Use the typed options classes—such as `GenerateLinkSignupOptions`—to build the request for each link type. + examples: + - id: generate-a-signup-link + name: Generate a signup link + isSpotlight: true + code: | + ```c# + var admin = supabase.AdminAuth(SUPABASE_SERVICE_KEY); + var response = await admin.GenerateLink(new GenerateLinkSignupOptions("user@example.com", "password")); + ``` + + - id: update-user-by-id + title: 'AdminAuth().UpdateUserById()' + description: | + Updates a user by their ID. + notes: | + - This is an admin method. Call it with your service role key, and only from a trusted server environment—never from a client app. + examples: + - id: update-a-user + name: Update a user + isSpotlight: true + code: | + ```c# + var admin = supabase.AdminAuth(SUPABASE_SERVICE_KEY); + var user = await admin.UpdateUserById(userId, new AdminUserAttributes + { + Email = "new-email@example.com" + }); + ``` + + - id: mfa-list-factors-admin + title: 'AdminAuth().ListFactors()' + description: | + Lists the MFA factors enrolled for a user. + notes: | + - This is an admin method. Call it with your service role key, and only from a trusted server environment—never from a client app. + examples: + - id: list-factors-for-a-user + name: List factors for a user + isSpotlight: true + code: | + ```c# + var admin = supabase.AdminAuth(SUPABASE_SERVICE_KEY); + var response = await admin.ListFactors(new MfaAdminListFactorsParams + { + UserId = userId + }); + ``` + + - id: mfa-delete-factor + title: 'AdminAuth().DeleteFactor()' + description: | + Deletes an MFA factor from a user. + notes: | + - This is an admin method. Call it with your service role key, and only from a trusted server environment—never from a client app. + examples: + - id: delete-a-factor-for-a-user + name: Delete a factor for a user + isSpotlight: true + code: | + ```c# + var admin = supabase.AdminAuth(SUPABASE_SERVICE_KEY); + await admin.DeleteFactor(new MfaAdminDeleteFactorParams + { + UserId = userId, + Id = factorId + }); + ``` + - id: invoke title: 'invoke()' description: | @@ -848,7 +1302,7 @@ functions: isSpotlight: true code: | ```c# - var channel = await supabase.From().On(ChannelEventType.All, (sender, change) => { }); + var channel = await supabase.From().On(ListenType.All, (sender, change) => { }); channel.Unsubscribe(); // OR @@ -978,7 +1432,7 @@ functions: name: Delete bucket isSpotlight: true code: | - ```dart + ```c# var result = await supabase.Storage.DeleteBucket("avatars"); ``` @@ -1049,6 +1503,24 @@ functions: .Move("public/fancy-avatar.png", "private/fancy-avatar.png"); ``` + - id: from-copy + description: | + Copies an existing file to a new path in the same bucket. + title: 'From().Copy()' + notes: | + - Policy permissions required: + - `buckets` permissions: none + - `objects` permissions: `select` and `insert` + examples: + - id: copy-file + name: Copy file + isSpotlight: true + code: | + ```c# + await supabase.Storage.From("avatars") + .Copy("public/fancy-avatar.png", "public/fancy-avatar-copy.png"); + ``` + - id: from-create-signed-url description: | Create signed url to download file without requiring permissions. This URL can be valid for a set number of seconds. @@ -1066,6 +1538,62 @@ functions: var url = await supabase.Storage.From("avatars").CreateSignedUrl("public/fancy-avatar.png", 60); ``` + - id: from-create-signed-urls + description: | + Creates signed URLs for multiple files at once. Each URL can be used to download a file without requiring permissions, and is valid for a set number of seconds. + title: 'From().CreateSignedUrls()' + notes: | + - Policy permissions required: + - `buckets` permissions: none + - `objects` permissions: `select` + examples: + - id: create-signed-urls + name: Create Signed URLs + isSpotlight: true + code: | + ```c# + var paths = new List { "public/fancy-avatar.png", "public/fancy-avatar-2.png" }; + var urls = await supabase.Storage.From("avatars").CreateSignedUrls(paths, 60); + ``` + + - id: from-create-signed-upload-url + description: | + Creates a signed URL that can be used to upload a file without requiring a logged-in user. This is useful for handing off uploads to an untrusted client. + title: 'From().CreateUploadSignedUrl()' + notes: | + - Policy permissions required: + - `buckets` permissions: none + - `objects` permissions: `insert` + - Pair this with [`UploadToSignedUrl()`](/docs/reference/csharp/from-upload-to-signed-url) to perform the upload. + examples: + - id: create-signed-upload-url + name: Create Signed Upload URL + isSpotlight: true + code: | + ```c# + var signedUrl = await supabase.Storage.From("avatars").CreateUploadSignedUrl("fancy-avatar.png"); + ``` + + - id: from-upload-to-signed-url + description: | + Uploads a file to a signed URL created with [`CreateUploadSignedUrl()`](/docs/reference/csharp/from-create-signed-upload-url). + title: 'From().UploadToSignedUrl()' + notes: | + - Policy permissions required: + - `buckets` permissions: none + - `objects` permissions: `insert` + examples: + - id: upload-to-signed-url + name: Upload to a Signed URL + isSpotlight: true + code: | + ```c# + var imagePath = Path.Combine("Assets", "fancy-avatar.png"); + var signedUrl = await supabase.Storage.From("avatars").CreateUploadSignedUrl("fancy-avatar.png"); + + await supabase.Storage.From("avatars").UploadToSignedUrl(imagePath, signedUrl); + ``` + - id: from-get-public-url description: | Retrieve URLs for assets in public buckets diff --git a/apps/studio/components/interfaces/Account/TOTPFactors/AddNewFactorModal.tsx b/apps/studio/components/interfaces/Account/TOTPFactors/AddNewFactorModal.tsx index 63c8901963764..8b6ce19fb05ab 100644 --- a/apps/studio/components/interfaces/Account/TOTPFactors/AddNewFactorModal.tsx +++ b/apps/studio/components/interfaces/Account/TOTPFactors/AddNewFactorModal.tsx @@ -59,25 +59,28 @@ interface FirstStepProps { onClose: () => void } +const ENROLL_FORM_ID = 'add-totp-factor-form' + +const EnrollFormSchema = z.object({ + name: z.string().trim().min(1, 'Name is required'), +}) +type EnrollFormValues = z.infer + +const enrollFormDefaultValues: EnrollFormValues = { name: '' } + const FirstStep = ({ visible, isEnrolling, enroll, onClose }: FirstStepProps) => { - const FormSchema = z.object({ - name: z.string().min(1, 'Please provide a name to identify this app'), - }) - const form = useForm>({ - resolver: zodResolver(FormSchema), - defaultValues: { name: '' }, + const form = useForm({ + resolver: zodResolver(EnrollFormSchema), + defaultValues: enrollFormDefaultValues, mode: 'onChange', }) - const onSubmit: SubmitHandler> = async (values) => { + const onSubmit: SubmitHandler = async (values) => { enroll({ factorType: 'totp', friendlyName: values.name }) } useEffect(() => { - if (!visible) { - // Generate a name with a number between 0 and 1000 - form.reset({ name: `App ${Math.floor(Math.random() * 1000)}` }) - } + if (visible) form.reset(enrollFormDefaultValues) }, [form, visible]) return ( @@ -93,7 +96,7 @@ const FirstStep = ({ visible, isEnrolling, enroll, onClose }: FirstStepProps) => >
@@ -104,11 +107,11 @@ const FirstStep = ({ visible, isEnrolling, enroll, onClose }: FirstStepProps) => render={({ field }) => ( - + )} diff --git a/apps/studio/components/interfaces/Account/TOTPFactors/index.tsx b/apps/studio/components/interfaces/Account/TOTPFactors/index.tsx index 28d2a728ee3ca..61c79ac6bec4e 100644 --- a/apps/studio/components/interfaces/Account/TOTPFactors/index.tsx +++ b/apps/studio/components/interfaces/Account/TOTPFactors/index.tsx @@ -18,7 +18,7 @@ export const TOTPFactors = () => { <>

- Use an authenticator app (like 1Password or Authy) to verify your identity at sign-in. + Use an authenticator app (like Google Authenticator or 1Password) to protect your account.

{isLoading && } diff --git a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx index 54026ead90bca..832f2769a756d 100644 --- a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx +++ b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx @@ -9,7 +9,7 @@ import { BannerTOSUpdate } from '@/components/ui/BannerStack/Banners/BannerTOSUp import { useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' -const TOSUpdateExpiry = new Date('2026-07-04T00:00:00Z') +const TOSUpdateExpiry = new Date('2026-08-29T00:00:00Z') export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => { const showNoticeBanner = useFlag('showNoticeBanner') diff --git a/apps/studio/components/interfaces/BranchManagement/ConnectToGitHub.tsx b/apps/studio/components/interfaces/BranchManagement/ConnectToGitHub.tsx new file mode 100644 index 0000000000000..d4256ee9e34ef --- /dev/null +++ b/apps/studio/components/interfaces/BranchManagement/ConnectToGitHub.tsx @@ -0,0 +1,56 @@ +import { useParams } from 'common' +import { Github } from 'lucide-react' +import { useRouter } from 'next/router' +import { Button } from 'ui' + +import { useGitHubAuthorizationQuery } from '@/data/integrations/github-authorization-query' +import { useGitHubConnectionsQuery } from '@/data/integrations/github-connections-query' +import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { openInstallGitHubIntegrationWindow } from '@/lib/github' +import { useAppStateSnapshot } from '@/state/app-state' + +export const ConnectToGitHub = () => { + const router = useRouter() + const { ref } = useParams() + const { data: project } = useSelectedProjectQuery() + const { data: selectedOrg } = useSelectedOrganizationQuery() + const { showCreateBranchModal, setShowCreateBranchModal } = useAppStateSnapshot() + + const isBranch = project?.parent_project_ref !== undefined + const projectRef = + project !== undefined ? (isBranch ? project.parent_project_ref : ref) : undefined + + const { data: gitHubAuthorization } = useGitHubAuthorizationQuery() + + const { data: connections } = useGitHubConnectionsQuery( + { organizationId: selectedOrg?.id }, + { enabled: showCreateBranchModal } + ) + const githubConnection = connections?.find((connection) => connection.project.ref === projectRef) + + const showAuthorizeCta = githubConnection && !gitHubAuthorization + + const onClick = () => { + if (showAuthorizeCta) { + openInstallGitHubIntegrationWindow('authorize') + } else { + if (showCreateBranchModal) setShowCreateBranchModal(false) + router.push(`/project/${projectRef}/settings/integrations`) + } + } + + return ( +
+
+ Sync with a GitHub branch +

+ Keep this preview branch in sync with a chosen GitHub branch +

+
+ +
+ ) +} diff --git a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx index 1f50e17d6d6e7..d282a1e5eebe3 100644 --- a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx +++ b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx @@ -3,7 +3,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useQueryClient } from '@tanstack/react-query' import { useDebounce } from '@uidotdev/usehooks' import { useFlag, useParams } from 'common' -import { Check, DatabaseZap, DollarSign, Github, GitMerge, Loader2 } from 'lucide-react' +import { Check, DatabaseZap, DollarSign, GitMerge, Loader2 } from 'lucide-react' import Image from 'next/image' import Link from 'next/link' import { useRouter } from 'next/router' @@ -25,7 +25,6 @@ import { FormControl, FormField, Input, - Label, Switch, Tooltip, TooltipContent, @@ -40,6 +39,7 @@ import { estimateDiskCost, estimateRestoreTime, } from './BranchManagement.utils' +import { ConnectToGitHub } from './ConnectToGitHub' import { TaxDisclaimer } from '@/components/interfaces/Billing/TaxDisclaimer' import { getInfrastructurePath } from '@/components/interfaces/Settings/Infrastructure/Infrastructure.utils' import { BranchingPITRNotice } from '@/components/layouts/AppLayout/EnableBranchingButton/BranchingPITRNotice' @@ -50,6 +50,7 @@ import { UpgradeToPro } from '@/components/ui/UpgradeToPro' import { useBranchCreateMutation } from '@/data/branches/branch-create-mutation' import { useBranchesQuery } from '@/data/branches/branches-query' import { DiskAttributesData, useDiskAttributesQuery } from '@/data/config/disk-attributes-query' +import { useGitHubAuthorizationQuery } from '@/data/integrations/github-authorization-query' import { useCheckGithubBranchValidity } from '@/data/integrations/github-branch-check-query' import { useGitHubConnectionsQuery } from '@/data/integrations/github-connections-query' import { projectKeys } from '@/data/projects/keys' @@ -118,6 +119,14 @@ export const CreateBranchModal = () => { }) const debouncedGitBranchName = useDebounce(gitBranchName, 500) + const { + data: githubAuthorization, + error: authorizationError, + isPending: isLoadingAuthorization, + isSuccess: isSuccessAuthorization, + isError: isErrorAuthorization, + } = useGitHubAuthorizationQuery() + const { data: connections, error: connectionsError, @@ -129,6 +138,11 @@ export const CreateBranchModal = () => { { enabled: showCreateBranchModal } ) + const isLoading = isLoadingAuthorization || isLoadingConnections + const isSuccess = isSuccessAuthorization && isSuccessConnections + const isError = isErrorAuthorization || isErrorConnections + const error = authorizationError || connectionsError + const { data: branches } = useBranchesQuery({ projectRef }) const { data: addons, isSuccess: isSuccessAddons } = useProjectAddonsQuery( { projectRef }, @@ -260,11 +274,6 @@ export const CreateBranchModal = () => { }) } - const handleGitHubClick = () => { - setShowCreateBranchModal(false) - router.push(`/project/${projectRef}/settings/integrations`) - } - useEffect(() => { if (showCreateBranchModal) form.reset() }, [form, showCreateBranchModal]) @@ -323,22 +332,24 @@ export const CreateBranchModal = () => { )} /> - {isLoadingConnections && ( + {isLoading && (
)} - {isErrorConnections && ( + {isError && ( )} - {isSuccessConnections && - (githubConnection ? ( + {isSuccess && + (!githubAuthorization || !githubConnection ? ( + + ) : ( {
} labelOptional="Optional" - description="Automatically deploy changes on every commit" + description={ + githubAuthorization + ? 'Automatically deploy changes on every commit' + : undefined + } >
@@ -394,18 +409,6 @@ export const CreateBranchModal = () => { )} /> - ) : ( -
-
- -

- Keep this preview branch in sync with a chosen GitHub branch -

-
- -
))} {allowDataBranching && ( @@ -416,7 +419,7 @@ export const CreateBranchModal = () => { - + Include data {!hasPitrEnabled && Requires PITR} } @@ -426,6 +429,7 @@ export const CreateBranchModal = () => { > { const { ref } = useParams() - const router = useRouter() const { data: projectDetails } = useSelectedProjectQuery() const { data: selectedOrg } = useSelectedOrganizationQuery() @@ -55,6 +54,14 @@ export const EditBranchModal = ({ branch, visible, onClose }: EditBranchModalPro const projectRef = projectDetails !== undefined ? (isBranch ? projectDetails.parent_project_ref : ref) : undefined + const { + data: githubAuthorization, + error: authorizationError, + isPending: isLoadingAuthorization, + isSuccess: isSuccessAuthorization, + isError: isErrorAuthorization, + } = useGitHubAuthorizationQuery() + const { data: connections, error: connectionsError, @@ -65,6 +72,11 @@ export const EditBranchModal = ({ branch, visible, onClose }: EditBranchModalPro organizationId: selectedOrg?.id, }) + const isLoading = isLoadingAuthorization || isLoadingConnections + const isSuccess = isSuccessAuthorization && isSuccessConnections + const isError = isErrorAuthorization || isErrorConnections + const error = authorizationError || connectionsError + const { data: branches } = useBranchesQuery({ projectRef }) const { mutate: checkGithubBranchValidity, isPending: isChecking } = useCheckGithubBranchValidity( { onError: () => {} } @@ -113,14 +125,6 @@ export const EditBranchModal = ({ branch, visible, onClose }: EditBranchModalPro const isFormValid = form.formState.isValid && (!gitBranchName || isGitBranchValid) const canSubmit = isFormValid && !isUpdating && !isChecking - const openLinkerPanel = () => { - onClose() - - if (projectRef) { - router.push(`/project/${projectRef}/settings/integrations`) - } - } - const onSubmit = (data: z.infer) => { if (!projectRef) return console.error('Project ref is required') if (!branch?.project_ref) return console.error('Branch ref is required') @@ -179,8 +183,9 @@ export const EditBranchModal = ({ branch, visible, onClose }: EditBranchModalPro if (form.getValues('gitBranchName') !== requested) return setIsGitBranchValid(false) form.setError('gitBranchName', { - ...error, - message: `Unable to find branch "${branchName}" in ${repoOwner}/${repoName}`, + message: + error?.message ?? + `Unable to find branch "${branchName}" in ${repoOwner}/${repoName}`, }) }, } @@ -234,22 +239,24 @@ export const EditBranchModal = ({ branch, visible, onClose }: EditBranchModalPro )} /> - {isLoadingConnections && ( + {isLoading && (
)} - {isErrorConnections && ( + {isError && ( )} - {isSuccessConnections && - (githubConnection ? ( + {isSuccess && + (!githubAuthorization || !githubConnection ? ( + + ) : ( )} /> - ) : ( -
-
-
- -
-

- Optionally connect to a GitHub repository to manage migrations automatically - for this branch. -

-
- -
))} diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx b/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx index 808e91f795042..8daa1afee7e98 100644 --- a/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx @@ -35,6 +35,7 @@ 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 { useIsHighAvailability } from '@/hooks/misc/useSelectedProject' import { DOCS_URL } from '@/lib/constants' import { pluckObjectFields } from '@/lib/helpers' @@ -50,10 +51,18 @@ interface ConnectStepsSectionProps { function useConnectionStringPooler(deploymentMode: DeploymentMode): ConnectionStringPooler { const { ref: projectRef } = useParams() const { hasAccess: allowPgBouncerSelection } = useCheckEntitlements('dedicated_pooler') + const isHighAvailability = useIsHighAvailability() const { data: settings } = useProjectSettingsV2Query({ projectRef }) - const { data: pgbouncerConfig } = usePgbouncerConfigQuery({ projectRef }) - const { data: supavisorConfig } = useSupavisorConfigurationQuery({ projectRef }) + // Multigres has no pooler, so the pooler config endpoints don't apply + const { data: pgbouncerConfig } = usePgbouncerConfigQuery( + { projectRef }, + { enabled: !isHighAvailability } + ) + const { data: supavisorConfig } = useSupavisorConfigurationQuery( + { projectRef }, + { enabled: !isHighAvailability } + ) const { data: addons } = useProjectAddonsQuery({ projectRef }) const { ipv4: ipv4Addon } = getAddons(addons?.selected_addons ?? []) @@ -113,8 +122,16 @@ function useConnectionStringPooler(deploymentMode: DeploymentMode): ConnectionSt connectionStringsShared, connectionStringsDedicated, ipv4Addon: !!ipv4Addon, + isHighAvailability, }), - [deploymentMode, connectionInfo, connectionStringsShared, connectionStringsDedicated, ipv4Addon] + [ + deploymentMode, + connectionInfo, + connectionStringsShared, + connectionStringsDedicated, + ipv4Addon, + isHighAvailability, + ] ) } diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectionString.utils.ts b/apps/studio/components/interfaces/ConnectSheet/ConnectionString.utils.ts index 16c3e9004478b..8158af03229c8 100644 --- a/apps/studio/components/interfaces/ConnectSheet/ConnectionString.utils.ts +++ b/apps/studio/components/interfaces/ConnectSheet/ConnectionString.utils.ts @@ -4,11 +4,17 @@ import type { ConnectionStringPooler } from './Connect.types' export const DEFAULT_PORT = '5432' export const PASSWORD_PLACEHOLDER = '[YOUR-PASSWORD]' +/** Appends query params to a connection string, joining with `?` or `&` as needed */ +export const appendConnectionStringParams = (uri: string, params: string) => + !uri || !params ? uri : `${uri}${uri.includes('?') ? '&' : '?'}${params}` + export type ConnectionParams = { host: string port: string user: string database: string + /** Raw query string including the leading `?`, or '' when the URI has none */ + search: string } export const resolveConnectionString = ({ @@ -44,6 +50,7 @@ export const parseConnectionParams = (connectionString: string): ConnectionParam port: DEFAULT_PORT, user: 'hidden', database: 'hidden', + search: '', } } @@ -64,6 +71,7 @@ export const parseConnectionParams = (connectionString: string): ConnectionParam port: parsed.port || DEFAULT_PORT, user: parsed.username ? decode(parsed.username) : 'hidden', database: parsed.pathname?.replace(/^\//, '') || 'hidden', + search: parsed.search, } } catch (error) { return { @@ -71,6 +79,7 @@ export const parseConnectionParams = (connectionString: string): ConnectionParam port: DEFAULT_PORT, user: 'hidden', database: 'hidden', + search: '', } } } @@ -81,15 +90,22 @@ export const buildSafeConnectionString = ( ): string => { if (!connectionString) return '' - const search = (() => { - try { - return new URL(connectionString).search - } catch (error) { - return '' - } - })() + return `postgresql://${params.user}:${PASSWORD_PLACEHOLDER}@${params.host}:${params.port}/${params.database}${params.search}` +} - return `postgresql://${params.user}:${PASSWORD_PLACEHOLDER}@${params.host}:${params.port}/${params.database}${search}` +export const buildPsqlCommand = (params: ConnectionParams) => + params.search + ? // Query params (e.g. sslmode) can't be expressed as psql flags, so fall + // back to the URI form — psql prompts for the password. + `psql "postgresql://${params.user}@${params.host}:${params.port}/${params.database}${params.search}"` + : `psql -h ${params.host} -p ${params.port} -d ${params.database} -U ${params.user}` + +export const buildJdbcString = (params: ConnectionParams) => { + // pgJDBC (42.7.4+) spells libpq's `sslnegotiation` as `sslNegotiation` + const extraParams = params.search + ? `&${params.search.slice(1).replace('sslnegotiation=', 'sslNegotiation=')}` + : '' + return `jdbc:postgresql://${params.host}:${params.port}/${params.database}?user=${params.user}&password=${PASSWORD_PLACEHOLDER}${extraParams}` } export const buildConnectionStringWithPassword = ( diff --git a/apps/studio/components/interfaces/ConnectSheet/DatabaseSettings.utils.ts b/apps/studio/components/interfaces/ConnectSheet/DatabaseSettings.utils.ts index e0e75995c5a0a..4233c2479eec4 100644 --- a/apps/studio/components/interfaces/ConnectSheet/DatabaseSettings.utils.ts +++ b/apps/studio/components/interfaces/ConnectSheet/DatabaseSettings.utils.ts @@ -1,4 +1,21 @@ import type { ConnectionStringPooler, DeploymentMode } from './Connect.types' +import { appendConnectionStringParams } from './ConnectionString.utils' + +/** + * Multigres (high-availability) projects only accept TLS connections with + * direct SSL negotiation — without these params clients fail with + * "server closed the connection unexpectedly". + */ +export const HIGH_AVAILABILITY_SSL_PARAMS = 'sslmode=require&sslnegotiation=direct' + +/** + * No-op when the URI already carries `sslnegotiation`, so the params are never + * double-appended. + */ +export const appendHighAvailabilitySslParams = (uri: string) => + uri.includes('sslnegotiation=') + ? uri + : appendConnectionStringParams(uri, HIGH_AVAILABILITY_SSL_PARAMS) type ConnectionStrings = { psql: string @@ -228,12 +245,14 @@ export const buildConnectionStringPooler = ({ connectionStringsShared, connectionStringsDedicated, ipv4Addon, + isHighAvailability, }: { deploymentMode: DeploymentMode connectionInfo: { db_host: string; db_port: number | string } connectionStringsShared: { direct: ConnectionStrings; pooler: ConnectionStrings } connectionStringsDedicated?: { direct: ConnectionStrings; pooler: ConnectionStrings } ipv4Addon: boolean + isHighAvailability: boolean }): ConnectionStringPooler => { if (deploymentMode.isSelfHosted) { const dbHost = connectionInfo.db_host @@ -265,6 +284,20 @@ export const buildConnectionStringPooler = ({ } } + if (isHighAvailability) { + // Multigres has no pooler (neither Supavisor nor PgBouncer), so every slot + // falls back to the direct connection. + const directUri = appendHighAvailabilitySslParams(connectionStringsShared.direct.uri) + return { + transactionShared: directUri, + sessionShared: directUri, + transactionDedicated: undefined, + sessionDedicated: undefined, + ipv4SupportedForDedicatedPooler: false, + direct: directUri, + } + } + // Port-swap 6543→5432 derives session from transaction. For shared this is a // real Supavisor session connection; for dedicated it lands on direct Postgres // (PgBouncer has no session mode). diff --git a/apps/studio/components/interfaces/ConnectSheet/OrmConnection.utils.ts b/apps/studio/components/interfaces/ConnectSheet/OrmConnection.utils.ts new file mode 100644 index 0000000000000..7b339be82effc --- /dev/null +++ b/apps/studio/components/interfaces/ConnectSheet/OrmConnection.utils.ts @@ -0,0 +1,39 @@ +import type { + ConnectionStringPooler, + DeploymentMode, +} from '@/components/interfaces/ConnectSheet/Connect.types' + +export type OrmConnectionScenario = + | 'cli' + | 'self-hosted' + | 'high-availability' + | 'dedicated-pooler' + | 'shared-pooler-with-dedicated-alternative' + | 'shared-pooler' + +/** + * Resolves which connection setup an ORM env template should render. + * Shared by the ORM step contents (Prisma, Drizzle) so they only differ in + * formatting, not in how the scenario is picked. + */ +export const resolveOrmConnectionScenario = ({ + connectionStringPooler, + deploymentMode, + isHighAvailability, +}: { + connectionStringPooler: ConnectionStringPooler + deploymentMode: DeploymentMode + isHighAvailability: boolean +}): OrmConnectionScenario => { + if (deploymentMode.isCli) return 'cli' + if (deploymentMode.isSelfHosted) return 'self-hosted' + if (isHighAvailability) return 'high-availability' + + if (connectionStringPooler.transactionDedicated) { + return connectionStringPooler.ipv4SupportedForDedicatedPooler + ? 'dedicated-pooler' + : 'shared-pooler-with-dedicated-alternative' + } + + return 'shared-pooler' +} diff --git a/apps/studio/components/interfaces/ConnectSheet/__tests__/ConnectionString.utils.test.ts b/apps/studio/components/interfaces/ConnectSheet/__tests__/ConnectionString.utils.test.ts index 834714b82764c..5a07d913ddfe3 100644 --- a/apps/studio/components/interfaces/ConnectSheet/__tests__/ConnectionString.utils.test.ts +++ b/apps/studio/components/interfaces/ConnectSheet/__tests__/ConnectionString.utils.test.ts @@ -1,8 +1,11 @@ import { describe, expect, test } from 'vitest' import { + appendConnectionStringParams, buildConnectionParameters, buildConnectionStringWithPassword, + buildJdbcString, + buildPsqlCommand, buildSafeConnectionString, DEFAULT_PORT, parseConnectionParams, @@ -17,6 +20,7 @@ describe('parseConnectionParams', () => { port: DEFAULT_PORT, user: 'hidden', database: 'hidden', + search: '', }) }) @@ -26,6 +30,7 @@ describe('parseConnectionParams', () => { port: DEFAULT_PORT, user: 'hidden', database: 'hidden', + search: '', }) }) @@ -37,9 +42,16 @@ describe('parseConnectionParams', () => { port: '6543', user: 'postgres.projref', database: 'postgres', + search: '', }) }) + test('keeps the query string in search', () => { + const uri = + 'postgresql://postgres:[YOUR-PASSWORD]@db.proj.supabase.co:5432/postgres?sslmode=require&sslnegotiation=direct' + expect(parseConnectionParams(uri).search).toBe('?sslmode=require&sslnegotiation=direct') + }) + test('decodes percent-encoded bracket placeholders in the user info', () => { // The URL parser percent-encodes the `[`/`]` in self-hosted's POOLER_TENANT_ID placeholder. // parseConnectionParams must decode so the displayed user matches what we wrote. @@ -159,6 +171,71 @@ describe('resolveConnectionString', () => { }) }) +describe('appendConnectionStringParams', () => { + test('joins with ? when the URI has no query string', () => { + expect(appendConnectionStringParams('postgresql://u@h:5432/db', 'pgbouncer=true')).toBe( + 'postgresql://u@h:5432/db?pgbouncer=true' + ) + }) + + test('joins with & when the URI already has a query string', () => { + expect( + appendConnectionStringParams('postgresql://u@h:5432/db?sslmode=require', 'pgbouncer=true') + ).toBe('postgresql://u@h:5432/db?sslmode=require&pgbouncer=true') + }) + + test('returns the URI unchanged for empty inputs', () => { + expect(appendConnectionStringParams('', 'pgbouncer=true')).toBe('') + expect(appendConnectionStringParams('postgresql://u@h:5432/db', '')).toBe( + 'postgresql://u@h:5432/db' + ) + }) +}) + +describe('buildPsqlCommand', () => { + const params = { + host: 'db.proj.supabase.co', + port: '5432', + user: 'postgres', + database: 'postgres', + search: '', + } + + test('uses flag form when there is no query string', () => { + expect(buildPsqlCommand(params)).toBe( + 'psql -h db.proj.supabase.co -p 5432 -d postgres -U postgres' + ) + }) + + test('falls back to the URI form when the query string must be carried', () => { + expect(buildPsqlCommand({ ...params, search: '?sslmode=require&sslnegotiation=direct' })).toBe( + 'psql "postgresql://postgres@db.proj.supabase.co:5432/postgres?sslmode=require&sslnegotiation=direct"' + ) + }) +}) + +describe('buildJdbcString', () => { + const params = { + host: 'db.proj.supabase.co', + port: '5432', + user: 'postgres', + database: 'postgres', + search: '', + } + + test('builds the base string without extra params', () => { + expect(buildJdbcString(params)).toBe( + `jdbc:postgresql://db.proj.supabase.co:5432/postgres?user=postgres&password=${PASSWORD_PLACEHOLDER}` + ) + }) + + test('appends the query string using pgJDBC casing for sslnegotiation', () => { + expect(buildJdbcString({ ...params, search: '?sslmode=require&sslnegotiation=direct' })).toBe( + `jdbc:postgresql://db.proj.supabase.co:5432/postgres?user=postgres&password=${PASSWORD_PLACEHOLDER}&sslmode=require&sslNegotiation=direct` + ) + }) +}) + describe('buildConnectionParameters', () => { test('produces host/port/database/user rows in display order', () => { expect( @@ -167,6 +244,7 @@ describe('buildConnectionParameters', () => { port: '5432', user: 'u', database: 'd', + search: '', }) ).toEqual([ { key: 'host', value: 'h' }, diff --git a/apps/studio/components/interfaces/ConnectSheet/__tests__/DatabaseSettings.utils.test.ts b/apps/studio/components/interfaces/ConnectSheet/__tests__/DatabaseSettings.utils.test.ts index c5697ed5655e0..dcfb2b95debe2 100644 --- a/apps/studio/components/interfaces/ConnectSheet/__tests__/DatabaseSettings.utils.test.ts +++ b/apps/studio/components/interfaces/ConnectSheet/__tests__/DatabaseSettings.utils.test.ts @@ -1,9 +1,11 @@ import { describe, expect, test } from 'vitest' import { + appendHighAvailabilitySslParams, buildConnectionStringPooler, getSelfHostedDirectStrings, getSelfHostedPoolerStrings, + HIGH_AVAILABILITY_SSL_PARAMS, } from '../DatabaseSettings.utils' import type { DeploymentMode } from '@/hooks/misc/useDeploymentMode' @@ -99,6 +101,7 @@ describe('buildConnectionStringPooler', () => { connectionStringsShared: sharedPlatform, connectionStringsDedicated: dedicatedPlatform, ipv4Addon: true, + isHighAvailability: false, }) expect(result.transactionShared).toBe(sharedPlatform.pooler.uri) @@ -115,6 +118,7 @@ describe('buildConnectionStringPooler', () => { connectionInfo, connectionStringsShared: sharedPlatform, ipv4Addon: false, + isHighAvailability: false, }) expect(result.ipv4SupportedForDedicatedPooler).toBe(false) expect(result.transactionDedicated).toBeUndefined() @@ -128,6 +132,7 @@ describe('buildConnectionStringPooler', () => { connectionInfo, connectionStringsShared: sharedPlatform, ipv4Addon: false, + isHighAvailability: false, }) expect(result.direct).toBe(directUri) @@ -144,6 +149,7 @@ describe('buildConnectionStringPooler', () => { connectionInfo: { db_host: 'supabase.example.com', db_port: 5432 }, connectionStringsShared: sharedPlatform, ipv4Addon: true, + isHighAvailability: false, }) expect(result.transactionShared).toBe( @@ -167,8 +173,69 @@ describe('buildConnectionStringPooler', () => { connectionInfo: { db_host: 'supabase.example.com', db_port: 0 }, connectionStringsShared: sharedPlatform, ipv4Addon: false, + isHighAvailability: false, }) expect(result.sessionShared).toContain(':5432/postgres') expect(result.direct).toContain(':5432/postgres') }) + + test('platform high availability: collapses every slot to the direct URI with SSL params', () => { + const directUri = `${sharedPlatform.direct.uri}?${HIGH_AVAILABILITY_SSL_PARAMS}` + const result = buildConnectionStringPooler({ + deploymentMode: platform, + connectionInfo, + connectionStringsShared: sharedPlatform, + connectionStringsDedicated: dedicatedPlatform, + ipv4Addon: true, + isHighAvailability: true, + }) + + expect(result.direct).toBe(directUri) + expect(result.transactionShared).toBe(directUri) + expect(result.sessionShared).toBe(directUri) + // No pooler on Multigres: dedicated slots stay empty and the IPv4 flag is + // off even when a dedicated pooler config and the addon were passed in + expect(result.transactionDedicated).toBeUndefined() + expect(result.sessionDedicated).toBeUndefined() + expect(result.ipv4SupportedForDedicatedPooler).toBe(false) + }) + + test('platform without high availability appends no SSL params', () => { + const result = buildConnectionStringPooler({ + deploymentMode: platform, + connectionInfo, + connectionStringsShared: sharedPlatform, + connectionStringsDedicated: dedicatedPlatform, + ipv4Addon: true, + isHighAvailability: false, + }) + + expect(result.direct).toBe(sharedPlatform.direct.uri) + expect(result.transactionShared).toBe(sharedPlatform.pooler.uri) + expect(result.direct).not.toContain('sslnegotiation') + expect(result.transactionShared).not.toContain('sslnegotiation') + }) +}) + +describe('appendHighAvailabilitySslParams', () => { + test('appends with ? when the URI has no query string', () => { + expect(appendHighAvailabilitySslParams('postgresql://u:p@host:5432/db')).toBe( + `postgresql://u:p@host:5432/db?${HIGH_AVAILABILITY_SSL_PARAMS}` + ) + }) + + test('appends with & when the URI already has a query string', () => { + expect(appendHighAvailabilitySslParams('postgresql://u:p@host:5432/db?options=x')).toBe( + `postgresql://u:p@host:5432/db?options=x&${HIGH_AVAILABILITY_SSL_PARAMS}` + ) + }) + + test('does not double-append when sslnegotiation is already present', () => { + const uri = `postgresql://u:p@host:5432/db?${HIGH_AVAILABILITY_SSL_PARAMS}` + expect(appendHighAvailabilitySslParams(uri)).toBe(uri) + }) + + test('leaves an empty string untouched', () => { + expect(appendHighAvailabilitySslParams('')).toBe('') + }) }) diff --git a/apps/studio/components/interfaces/ConnectSheet/__tests__/OrmConnection.utils.test.ts b/apps/studio/components/interfaces/ConnectSheet/__tests__/OrmConnection.utils.test.ts new file mode 100644 index 0000000000000..c511b002921c9 --- /dev/null +++ b/apps/studio/components/interfaces/ConnectSheet/__tests__/OrmConnection.utils.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from 'vitest' + +import type { ConnectionStringPooler } from '../Connect.types' +import { resolveOrmConnectionScenario } from '../OrmConnection.utils' +import type { DeploymentMode } from '@/hooks/misc/useDeploymentMode' + +const platform: DeploymentMode = { isPlatform: true, isCli: false, isSelfHosted: false } +const cli: DeploymentMode = { isPlatform: false, isCli: true, isSelfHosted: false } +const selfHosted: DeploymentMode = { isPlatform: false, isCli: false, isSelfHosted: true } + +const makePooler = (overrides: Partial = {}): ConnectionStringPooler => ({ + transactionShared: 'postgresql://shared:6543/postgres', + sessionShared: 'postgresql://shared:5432/postgres', + ipv4SupportedForDedicatedPooler: false, + direct: 'postgresql://direct:5432/postgres', + ...overrides, +}) + +describe('resolveOrmConnectionScenario', () => { + test('resolves cli for CLI deployments', () => { + const scenario = resolveOrmConnectionScenario({ + connectionStringPooler: makePooler(), + deploymentMode: cli, + isHighAvailability: false, + }) + expect(scenario).toBe('cli') + }) + + test('resolves self-hosted for self-hosted deployments', () => { + const scenario = resolveOrmConnectionScenario({ + connectionStringPooler: makePooler(), + deploymentMode: selfHosted, + isHighAvailability: false, + }) + expect(scenario).toBe('self-hosted') + }) + + test('resolves high-availability for HA projects on platform', () => { + const scenario = resolveOrmConnectionScenario({ + connectionStringPooler: makePooler(), + deploymentMode: platform, + isHighAvailability: true, + }) + expect(scenario).toBe('high-availability') + }) + + test('cli wins over the HA flag', () => { + const scenario = resolveOrmConnectionScenario({ + connectionStringPooler: makePooler(), + deploymentMode: cli, + isHighAvailability: true, + }) + expect(scenario).toBe('cli') + }) + + test('resolves dedicated-pooler when the dedicated pooler exists and IPv4 is supported', () => { + const scenario = resolveOrmConnectionScenario({ + connectionStringPooler: makePooler({ + transactionDedicated: 'postgresql://dedicated:6543/postgres', + sessionDedicated: 'postgresql://dedicated:5432/postgres', + ipv4SupportedForDedicatedPooler: true, + }), + deploymentMode: platform, + isHighAvailability: false, + }) + expect(scenario).toBe('dedicated-pooler') + }) + + test('resolves shared-pooler-with-dedicated-alternative when the dedicated pooler exists without IPv4 support', () => { + const scenario = resolveOrmConnectionScenario({ + connectionStringPooler: makePooler({ + transactionDedicated: 'postgresql://dedicated:6543/postgres', + sessionDedicated: 'postgresql://dedicated:5432/postgres', + ipv4SupportedForDedicatedPooler: false, + }), + deploymentMode: platform, + isHighAvailability: false, + }) + expect(scenario).toBe('shared-pooler-with-dedicated-alternative') + }) + + test('falls back to shared-pooler when there is no dedicated pooler', () => { + const scenario = resolveOrmConnectionScenario({ + connectionStringPooler: makePooler(), + deploymentMode: platform, + isHighAvailability: false, + }) + expect(scenario).toBe('shared-pooler') + }) +}) diff --git a/apps/studio/components/interfaces/ConnectSheet/__tests__/useConnectState.test.ts b/apps/studio/components/interfaces/ConnectSheet/__tests__/useConnectState.test.ts index 5e9ac4a6b3d07..8b6a5b21b67dc 100644 --- a/apps/studio/components/interfaces/ConnectSheet/__tests__/useConnectState.test.ts +++ b/apps/studio/components/interfaces/ConnectSheet/__tests__/useConnectState.test.ts @@ -498,6 +498,11 @@ describe('useConnectState', () => { // ============================================================================ describe('high availability projects', () => { + afterEach(async () => { + const { useIsHighAvailability } = await import('@/hooks/misc/useSelectedProject') + vi.mocked(useIsHighAvailability).mockReturnValue(false) + }) + test('should hide connectionMethod field for HA projects', async () => { const { useIsHighAvailability } = await import('@/hooks/misc/useSelectedProject') vi.mocked(useIsHighAvailability).mockReturnValue(true) @@ -530,6 +535,34 @@ describe('useConnectState', () => { expect(connectionTypeField?.label).toBe('Connection Type') }) + test('should coerce pooler-flavored initial state to the direct method for HA projects', async () => { + const { useIsHighAvailability } = await import('@/hooks/misc/useSelectedProject') + vi.mocked(useIsHighAvailability).mockReturnValue(true) + + // Simulates pooler selections restored from the URL or localStorage + const { result } = renderHook(() => + useConnectState({ mode: 'direct', connectionMethod: 'transaction', useSharedPooler: true }) + ) + + expect(result.current.state.connectionMethod).toBe('direct') + expect(result.current.state.useSharedPooler).toBe(false) + }) + + test('should coerce connectionMethod updates to the direct method for HA projects', async () => { + const { useIsHighAvailability } = await import('@/hooks/misc/useSelectedProject') + vi.mocked(useIsHighAvailability).mockReturnValue(true) + + const { result } = renderHook(() => useConnectState({ mode: 'direct' })) + + act(() => { + result.current.updateField('connectionMethod', 'session') + result.current.updateField('useSharedPooler', true) + }) + + expect(result.current.state.connectionMethod).toBe('direct') + expect(result.current.state.useSharedPooler).toBe(false) + }) + test('should not affect non-HA projects', async () => { const { useIsHighAvailability } = await import('@/hooks/misc/useSelectedProject') vi.mocked(useIsHighAvailability).mockReturnValue(false) diff --git a/apps/studio/components/interfaces/ConnectSheet/content/drizzle/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/drizzle/content.tsx index 10a6a6106d171..84d7e158fb709 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/drizzle/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/drizzle/content.tsx @@ -1,37 +1,68 @@ import { MultipleCodeBlock } from 'ui-patterns/MultipleCodeBlock' -import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types' +import type { + ConnectionStringPooler, + DeploymentMode, + StepContentProps, +} from '@/components/interfaces/ConnectSheet/Connect.types' +import { resolveOrmConnectionScenario } from '@/components/interfaces/ConnectSheet/OrmConnection.utils' +import { useIsHighAvailability } from '@/hooks/misc/useSelectedProject' -const ContentFile = ({ connectionStringPooler, deploymentMode }: StepContentProps) => { - const envCode = deploymentMode.isCli - ? ` +function getEnvCode({ + connectionStringPooler, + deploymentMode, + isHighAvailability, +}: { + connectionStringPooler: ConnectionStringPooler + deploymentMode: DeploymentMode + isHighAvailability: boolean +}): string { + const scenario = resolveOrmConnectionScenario({ + connectionStringPooler, + deploymentMode, + isHighAvailability, + }) + + switch (scenario) { + case 'cli': + return ` # Connect to Postgres via the direct connection DATABASE_URL="${connectionStringPooler.direct}" ` - : deploymentMode.isSelfHosted - ? ` + case 'self-hosted': + return ` # Connect to Postgres via the self-hosted transaction-mode pooler DATABASE_URL="${connectionStringPooler.transactionShared}" ` - : connectionStringPooler.transactionDedicated && - connectionStringPooler.ipv4SupportedForDedicatedPooler - ? ` + case 'high-availability': + return ` +# Multigres does not support connection pooling — connect to Postgres directly +DATABASE_URL="${connectionStringPooler.direct}" +` + case 'dedicated-pooler': + return ` # Connect to Postgres via the dedicated transaction-mode pooler (IPv4-only) DATABASE_URL="${connectionStringPooler.transactionDedicated}" ` - : connectionStringPooler.transactionDedicated && - !connectionStringPooler.ipv4SupportedForDedicatedPooler - ? ` + case 'shared-pooler-with-dedicated-alternative': + return ` # Connect to Postgres via the shared transaction-mode pooler (IPv4-only) DATABASE_URL="${connectionStringPooler.transactionShared}" # For paid projects, if your network supports IPv6, or you purchased the IPv4 add-on, use the dedicated transaction-mode pooler as an alternative # DATABASE_URL="${connectionStringPooler.transactionDedicated}" ` - : ` + case 'shared-pooler': + return ` # Connect to Postgres via the shared transaction-mode pooler (IPv4-only) DATABASE_URL="${connectionStringPooler.transactionShared}" ` + } +} + +const ContentFile = ({ connectionStringPooler, deploymentMode }: StepContentProps) => { + const isHighAvailability = useIsHighAvailability() + const envCode = getEnvCode({ connectionStringPooler, deploymentMode, isHighAvailability }) const files = [ { diff --git a/apps/studio/components/interfaces/ConnectSheet/content/prisma/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/prisma/content.tsx index d331286607c41..86e2761783ee7 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/prisma/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/prisma/content.tsx @@ -1,53 +1,91 @@ import { MultipleCodeBlock } from 'ui-patterns/MultipleCodeBlock' -import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types' +import type { + ConnectionStringPooler, + DeploymentMode, + StepContentProps, +} from '@/components/interfaces/ConnectSheet/Connect.types' +import { appendConnectionStringParams } from '@/components/interfaces/ConnectSheet/ConnectionString.utils' +import { resolveOrmConnectionScenario } from '@/components/interfaces/ConnectSheet/OrmConnection.utils' +import { useIsHighAvailability } from '@/hooks/misc/useSelectedProject' -const ContentFile = ({ connectionStringPooler, deploymentMode }: StepContentProps) => { - const envCode = deploymentMode.isCli - ? ` +const withPgbouncerParam = (uri: string | undefined) => + appendConnectionStringParams(uri ?? '', 'pgbouncer=true') + +function getEnvCode({ + connectionStringPooler, + deploymentMode, + isHighAvailability, +}: { + connectionStringPooler: ConnectionStringPooler + deploymentMode: DeploymentMode + isHighAvailability: boolean +}): string { + const scenario = resolveOrmConnectionScenario({ + connectionStringPooler, + deploymentMode, + isHighAvailability, + }) + + switch (scenario) { + case 'cli': + return ` # Connect to Postgres via the direct connection DATABASE_URL="${connectionStringPooler.direct}" # Used for migrations DIRECT_URL="${connectionStringPooler.direct}" ` - : deploymentMode.isSelfHosted - ? ` + case 'self-hosted': + return ` # Connect to Postgres via the self-hosted transaction-mode pooler -DATABASE_URL="${connectionStringPooler.transactionShared}?pgbouncer=true" +DATABASE_URL="${withPgbouncerParam(connectionStringPooler.transactionShared)}" # Connect to Postgres via the self-hosted session-mode pooler (used for migrations) DIRECT_URL="${connectionStringPooler.sessionShared}" ` - : connectionStringPooler.transactionDedicated && - connectionStringPooler.ipv4SupportedForDedicatedPooler - ? ` + case 'high-availability': + return ` +# Multigres does not support connection pooling — connect to Postgres directly +DATABASE_URL="${connectionStringPooler.direct}" + +# Used for migrations +DIRECT_URL="${connectionStringPooler.direct}" +` + case 'dedicated-pooler': + return ` # Connect to Postgres via the dedicated transaction-mode pooler (IPv4-only) -DATABASE_URL="${connectionStringPooler.transactionDedicated}?pgbouncer=true" +DATABASE_URL="${withPgbouncerParam(connectionStringPooler.transactionDedicated)}" # Connect to Postgres directly (used for migrations) DIRECT_URL="${connectionStringPooler.sessionDedicated}" ` - : connectionStringPooler.transactionDedicated && - !connectionStringPooler.ipv4SupportedForDedicatedPooler - ? ` + case 'shared-pooler-with-dedicated-alternative': + return ` # Connect to Postgres via the shared transaction-mode pooler (IPv4-only) -DATABASE_URL="${connectionStringPooler.transactionShared}?pgbouncer=true" +DATABASE_URL="${withPgbouncerParam(connectionStringPooler.transactionShared)}" # Connect to Postgres via the shared session-mode pooler (used for migrations) DIRECT_URL="${connectionStringPooler.sessionShared}" # For paid projects, if your network supports IPv6, or you purchased the IPv4 add-on, use the dedicated transaction-mode pooler with a direct connection to Postgres for migrations as an alternative -# DATABASE_URL="${connectionStringPooler.transactionDedicated}?pgbouncer=true" +# DATABASE_URL="${withPgbouncerParam(connectionStringPooler.transactionDedicated)}" # DIRECT_URL="${connectionStringPooler.sessionDedicated}" ` - : ` + case 'shared-pooler': + return ` # Connect to Postgres via the shared transaction-mode pooler (IPv4-only) -DATABASE_URL="${connectionStringPooler.transactionShared}?pgbouncer=true" +DATABASE_URL="${withPgbouncerParam(connectionStringPooler.transactionShared)}" # Connect to Postgres via the shared session-mode pooler (used for migrations) DIRECT_URL="${connectionStringPooler.sessionShared}" ` + } +} + +const ContentFile = ({ connectionStringPooler, deploymentMode }: StepContentProps) => { + const isHighAvailability = useIsHighAvailability() + const envCode = getEnvCode({ connectionStringPooler, deploymentMode, isHighAvailability }) const files = [ { diff --git a/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx index c5b79d422d9aa..d6d3dc3c130a8 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx @@ -20,6 +20,8 @@ import { ConnectionParameters } from '@/components/interfaces/ConnectSheet/Conne import { buildConnectionParameters, buildConnectionStringWithPassword, + buildJdbcString, + buildPsqlCommand, buildSafeConnectionString, parseConnectionParams, PASSWORD_PLACEHOLDER, @@ -38,12 +40,6 @@ import { DOCS_URL } from '@/lib/constants' import { pluckObjectFields } from '@/lib/helpers' import { useTrack } from '@/lib/telemetry/track' -const buildPsqlCommand = (params: { host: string; port: string; database: string; user: string }) => - `psql -h ${params.host} -p ${params.port} -d ${params.database} -U ${params.user}` - -const buildJdbcString = (params: { host: string; port: string; database: string; user: string }) => - `jdbc:postgresql://${params.host}:${params.port}/${params.database}?user=${params.user}&password=${PASSWORD_PLACEHOLDER}` - /** * [Joshen] ConnectStepsSection does something similar but since only this page needs to consider connection strings * from all databases (including read replicas), am opting to separate the logic for retrieving connection strings here @@ -54,10 +50,18 @@ const buildJdbcString = (params: { host: string; port: string; database: string; const useConnectionStringDatabases = (deploymentMode: DeploymentMode) => { const { ref: projectRef } = useParams() const { hasAccess: allowPgBouncerSelection } = useCheckEntitlements('dedicated_pooler') + const isHighAvailability = useIsHighAvailability() const { data: databases = [] } = useReadReplicasQuery({ projectRef }) - const { data: pgbouncerConfig } = usePgbouncerConfigQuery({ projectRef }) - const { data: supavisorConfig } = useSupavisorConfigurationQuery({ projectRef }) + // Multigres has no pooler, so the pooler config endpoints don't apply + const { data: pgbouncerConfig } = usePgbouncerConfigQuery( + { projectRef }, + { enabled: !isHighAvailability } + ) + const { data: supavisorConfig } = useSupavisorConfigurationQuery( + { projectRef }, + { enabled: !isHighAvailability } + ) const { data: addons } = useProjectAddonsQuery({ projectRef }) const { ipv4: ipv4Addon } = getAddons(addons?.selected_addons ?? []) @@ -115,6 +119,7 @@ const useConnectionStringDatabases = (deploymentMode: DeploymentMode) => { connectionStringsShared, connectionStringsDedicated, ipv4Addon: !!ipv4Addon, + isHighAvailability, }), ] }) @@ -127,6 +132,7 @@ const useConnectionStringDatabases = (deploymentMode: DeploymentMode) => { ipv4Addon, projectRef, deploymentMode, + isHighAvailability, ]) } diff --git a/apps/studio/components/interfaces/ConnectSheet/useConnectState.ts b/apps/studio/components/interfaces/ConnectSheet/useConnectState.ts index 85325cb8eff00..82dbe3966de24 100644 --- a/apps/studio/components/interfaces/ConnectSheet/useConnectState.ts +++ b/apps/studio/components/interfaces/ConnectSheet/useConnectState.ts @@ -359,8 +359,17 @@ export function useConnectState(initialState?: Partial): UseConnec [projectRef, deploymentMode.isSelfHosted, deploymentMode.isPlatform] ) + // Multigres has no pooler, so pooler-flavored selections restored from the + // URL or localStorage (shared across projects) must never leak into an HA + // project — every consumer sees the direct connection method. + const resolvedState = useMemo( + () => + isHighAvailability ? { ...state, connectionMethod: 'direct', useSharedPooler: false } : state, + [state, isHighAvailability] + ) + const activeFields = useMemo(() => { - let fields = getActiveFields(connectSchema, state) + let fields = getActiveFields(connectSchema, resolvedState) if (!hasDedicatedPooler || !deploymentMode.isPlatform) { // useSharedPooler is a platform-only toggle (CLI has no pooler; self-hosted // already uses Supavisor shared) @@ -372,21 +381,26 @@ export function useConnectState(initialState?: Partial): UseConnec .map((f) => (f.id === 'connectionType' ? { ...f, label: 'Connection Type' } : f)) } return fields - }, [state, hasDedicatedPooler, isHighAvailability, deploymentMode.isPlatform]) + }, [resolvedState, hasDedicatedPooler, isHighAvailability, deploymentMode.isPlatform]) - const resolvedSteps = useMemo(() => resolveSteps(connectSchema, state), [state]) + const resolvedSteps = useMemo(() => resolveSteps(connectSchema, resolvedState), [resolvedState]) const getFieldOptions = useCallback( (fieldId: string): FieldOption[] => { const field = activeFields.find((f) => f.id === fieldId) if (!field) return [] - return resolveFieldOptionsWithSource({ field, state, databases, deploymentMode }) + return resolveFieldOptionsWithSource({ + field, + state: resolvedState, + databases, + deploymentMode, + }) }, - [activeFields, state, databases, deploymentMode] + [activeFields, resolvedState, databases, deploymentMode] ) return { - state, + state: resolvedState, updateField, setMode, activeFields, diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx index d0120d9ad5920..d7f6f9cdcd774 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx @@ -28,7 +28,6 @@ import { ReadReplicaForm } from './ReadReplicaForm' import { DocsButton } from '@/components/ui/DocsButton' import { useReplicationDestinationsQuery } from '@/data/replication/destinations-query' import { checkLocalETLNotSetUp } from '@/data/replication/utils' -import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' import { DOCS_URL } from '@/lib/constants' interface DestinationPanelProps { @@ -38,7 +37,6 @@ interface DestinationPanelProps { export const DestinationPanel = ({ onSuccessCreateReadReplica }: DestinationPanelProps) => { const { ref: projectRef } = useParams() const enablePgReplicate = useIsETLPrivateAlpha() - const { hasAccess: hasETLReplicationAccess } = useCheckEntitlements('replication.etl') const { error: destinationsError } = useReplicationDestinationsQuery({ projectRef }) const isLocalETLNotSetUp = checkLocalETLNotSetUp(destinationsError) @@ -196,11 +194,7 @@ export const DestinationPanel = ({ onSuccessCreateReadReplica }: DestinationPane
{pipelinesTypeSelection} - +
) : ( diff --git a/apps/studio/components/interfaces/Database/Replication/Destinations.tsx b/apps/studio/components/interfaces/Database/Replication/Destinations.tsx index 52767d0788d86..f316069eb7769 100644 --- a/apps/studio/components/interfaces/Database/Replication/Destinations.tsx +++ b/apps/studio/components/interfaces/Database/Replication/Destinations.tsx @@ -30,6 +30,7 @@ import { DestinationPanel } from './DestinationPanel/DestinationPanel' import { DestinationType } from './DestinationPanel/DestinationPanel.types' import { DestinationRow } from './DestinationRow' import { DisablePipelinesDialog } from './DisablePipelinesDialog' +import { EnablePipelinesModal } from './EnablePipelinesCallout' import { ReadReplicaRow } from './ReadReplicas/ReadReplicaRow' import { useIsETLBigQueryPrivateAlpha, @@ -86,6 +87,7 @@ export const Destinations = () => { const searchInputRef = useRef(null) const [filterString, setFilterString] = useState('') const [statusRefetchInterval, setStatusRefetchInterval] = useState(5000) + const [showEnablePipelinesDialog, setShowEnablePipelinesDialog] = useState(false) const [showDisablePipelinesDialog, setShowDisablePipelinesDialog] = useState(false) const [_, setDestinationType] = useQueryState( @@ -157,6 +159,8 @@ export const Destinations = () => { () => sourcesData?.sources.find((source) => source.name === projectRef), [projectRef, sourcesData?.sources] ) + const replicationNotEnabled = isSourcesSuccess && !externalReplicationSource + const canDisablePipelines = isSourcesSuccess && isDestinationsSuccess && @@ -270,18 +274,24 @@ export const Destinations = () => { - setShowDisablePipelinesDialog(true)} - > - Disable Pipelines - + {replicationNotEnabled ? ( + setShowEnablePipelinesDialog(true)}> + Enable Pipelines + + ) : ( + setShowDisablePipelinesDialog(true)} + > + Disable Pipelines + + )} @@ -391,6 +401,11 @@ export const Destinations = () => { setStatusRefetchInterval(5000)} /> + + Disable Pipelines - -

+ + This will remove the etl schema and all Pipelines-managed resources from your database. Data already written to destination systems is not deleted. -

-

Read replicas are not affected.

+ + Read replicas are not affected.
{error && ( diff --git a/apps/studio/components/interfaces/Database/Replication/EnablePipelinesCallout.tsx b/apps/studio/components/interfaces/Database/Replication/EnablePipelinesCallout.tsx index 6d3637ac4b93b..0c0eb4151bd0e 100644 --- a/apps/studio/components/interfaces/Database/Replication/EnablePipelinesCallout.tsx +++ b/apps/studio/components/interfaces/Database/Replication/EnablePipelinesCallout.tsx @@ -20,11 +20,25 @@ import { DocsButton } from '@/components/ui/DocsButton' import { InlineLink } from '@/components/ui/InlineLink' import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton' import { useCreateTenantSourceMutation } from '@/data/replication/create-tenant-source-mutation' +import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' import { DOCS_URL } from '@/lib/constants' -const EnablePipelinesModal = () => { +type EnablePipelinesModalProps = + | { open: boolean; onOpenChange: (open: boolean) => void } + | { open?: never; onOpenChange?: never } + +export const EnablePipelinesModal = ({ + open: extOpen, + onOpenChange, +}: EnablePipelinesModalProps) => { const { ref: projectRef } = useParams() - const [open, setOpen] = useState(false) + const [_open, _setOpen] = useState(false) + + const open = extOpen ?? _open + const setOpen = onOpenChange ?? _setOpen + const hideTrigger = extOpen !== undefined && onOpenChange !== undefined + + const { hasAccess } = useCheckEntitlements('replication.etl') const { mutate: createTenantSource, isPending: creatingTenantSource } = useCreateTenantSourceMutation({ @@ -44,12 +58,14 @@ const EnablePipelinesModal = () => { return ( - - - - + {!hideTrigger && ( + + + + )} + Enable Pipelines @@ -60,27 +76,41 @@ const EnablePipelinesModal = () => { className="rounded-none border-0" title="Pipelines is currently in public alpha" > -

- Public alpha features may change as we refine the product and incorporate customer - feedback. -

-

- Pipelines is billed for configured pipeline hours and Postgres row data processed - during initial sync and ongoing replication. Review the{' '} - - Pipelines pricing - {' '} - before enabling it. -

+ {hasAccess ? ( + <> +

+ Public alpha features may change as we refine the product and incorporate customer + feedback. +

+

+ Pipelines is billed for configured pipeline hours and Postgres row data processed + during initial sync and ongoing replication. Review the{' '} + + Pipelines pricing + {' '} + before enabling it. +

+ + ) : ( +

+ Supabase Pipelines replicates database changes to supported destination systems.{' '} + {hasAccess ? 'Enable Pipelines for your project' : 'Upgrade to the Pro plan'} to + replicate database changes to data warehouses and analytics platforms. +

+ )} - + {hasAccess ? ( + + ) : ( + + )}
@@ -90,12 +120,12 @@ const EnablePipelinesModal = () => { export const EnablePipelinesCallout = ({ type, className, - hasAccess, }: { type?: DestinationType | null className?: string - hasAccess: boolean }) => { + const { hasAccess } = useCheckEntitlements('replication.etl') + return (
diff --git a/apps/studio/components/interfaces/Organization/Documents/DPA.tsx b/apps/studio/components/interfaces/Organization/Documents/DPA.tsx index d66e124a097ec..974ccc2621f54 100644 --- a/apps/studio/components/interfaces/Organization/Documents/DPA.tsx +++ b/apps/studio/components/interfaces/Organization/Documents/DPA.tsx @@ -1,5 +1,3 @@ -import { useState } from 'react' -import { toast } from 'sonner' import { Button } from 'ui' import { @@ -8,96 +6,38 @@ import { ScaffoldSectionDetail, } from '@/components/layouts/Scaffold' import { InlineLink } from '@/components/ui/InlineLink' -import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper' -import { useDpaRequestMutation } from '@/data/documents/dpa-request-mutation' -import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' -import { useProfile } from '@/lib/profile' import { useTrack } from '@/lib/telemetry/track' export const DPA = () => { - const { profile } = useProfile() - const { data: organization } = useSelectedOrganizationQuery() - const slug = organization?.slug - - const [isOpen, setIsOpen] = useState(false) - const track = useTrack() - const { mutate: requestDpa, isPending: isRequesting } = useDpaRequestMutation({ - onSuccess: () => { - toast.success('DPA request sent successfully') - setIsOpen(false) - }, - }) - - const onConfirmRequest = async () => { - if (!slug) return toast.error('Organization not found.') - if (!profile?.primary_email) return toast.error('Profile email not found.') - requestDpa({ recipient_email: profile?.primary_email, slug: slug }) - } return ( - <> - - -

Data Processing Addendum (DPA)

-
-

- All organizations can sign our Data Processing Addendum ("DPA") as part of their GDPR - compliance. -

-

- You can review a static PDF version of our latest DPA document{' '} - track('dpa_pdf_opened', { source: 'studio' })} - > - here - - . -

-
-
- -
- -
-
-
- - setIsOpen(false)} - onConfirm={() => onConfirmRequest()} - > -
+ + +

Data Processing Addendum (DPA)

+

- To make the DPA legally binding, you need to sign and complete the details through a - PandaDoc document that we prepare. -

-

- Please enter your email address to request an executable version of the DPA. You will - receive a document link via PandaDoc in the next 24 hours. -

-

- Once signed, the DPA will be considered executed and you'll be notified of any future - updates via this email. + Our Data Processing Addendum is incorporated into our{' '} + Terms of Service, so all + organizations get its protections automatically. No separate signed DPA is needed.

+

If you signed a DPA with us previously, that agreement remains binding.

+
+
+ + - - + +
) } diff --git a/apps/studio/components/interfaces/ProjectHome/ProjectConnectionPopover.tsx b/apps/studio/components/interfaces/ProjectHome/ProjectConnectionPopover.tsx index bbb20bc3fa0d2..a138e583d95e0 100644 --- a/apps/studio/components/interfaces/ProjectHome/ProjectConnectionPopover.tsx +++ b/apps/studio/components/interfaces/ProjectHome/ProjectConnectionPopover.tsx @@ -15,10 +15,12 @@ import { import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { getConnectionStrings } from '@/components/interfaces/Connect/DatabaseSettings.utils' +import { appendHighAvailabilitySslParams } from '@/components/interfaces/ConnectSheet/DatabaseSettings.utils' import { useAPIKeys } from '@/data/api-keys/api-keys-query' import { useProjectApiUrl } from '@/data/config/project-endpoint-query' import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' +import { useIsHighAvailability } from '@/hooks/misc/useSelectedProject' import { IS_PLATFORM } from '@/lib/constants' import { pluckObjectFields } from '@/lib/helpers' @@ -57,6 +59,7 @@ export const ProjectConnectionPopover = ({ projectRef }: ProjectConnectionPopove { enabled: IS_PLATFORM && open && !!projectRef } ) const primaryDatabase = databases?.find((db) => db.identifier === projectRef) + const isHighAvailability = useIsHighAvailability() const directConnectionString = useMemo(() => { if ( @@ -68,11 +71,12 @@ export const ProjectConnectionPopover = ({ projectRef }: ProjectConnectionPopove return '' } const connectionInfo = pluckObjectFields(primaryDatabase, [...DB_FIELDS]) - return getConnectionStrings({ + const uri = getConnectionStrings({ connectionInfo: { ...EMPTY_CONNECTION_INFO, ...connectionInfo }, metadata: { projectRef }, }).direct.uri - }, [primaryDatabase, projectRef]) + return isHighAvailability ? appendHighAvailabilitySslParams(uri) : uri + }, [primaryDatabase, projectRef, isHighAvailability]) const cliCommands = useMemo( () => diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx index aafba624bafe6..ca13118c5bcf1 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx @@ -6,13 +6,14 @@ import { useParams, useSearchParamsShallow } from 'common/hooks' import { AnimatePresence, motion } from 'framer-motion' import { Eraser, Pencil, X } from 'lucide-react' import { useRouter } from 'next/router' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Button, cn, KeyboardShortcut } from 'ui' import { Admonition } from 'ui-patterns/Admonition' import { AlertError } from '../AlertError' import { ButtonTooltip } from '../ButtonTooltip' import { ErrorBoundary } from '../ErrorBoundary/ErrorBoundary' +import { InlineLinkClassName } from '../InlineLink' import { ASSISTANT_ERRORS } from './AiAssistant.constants' import type { SqlSnippet } from './AIAssistant.types' import { @@ -186,6 +187,9 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { const isChatInputDisabled = !isApiKeySet || disablePrompts || isLoadingOrganization || isSupportChatClosed + const branchedFrom = snap.activeChat?.branchedFrom + const branchedConversation = branchedFrom ? snap.chats[branchedFrom.chatId] : undefined + const deleteMessageFromHere = useCallback( (messageId: string) => { // Find the message index in current chatMessages @@ -289,22 +293,39 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { const isLastMessage = index === chatMessages.length - 1 return ( - + + + {branchedConversation && branchedFrom?.messageId === message.id && ( +
+
+
+ Branched from + +
+
+
+ )} + ) }), [ @@ -317,6 +338,9 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { addToolApprovalResponse, handleRateMessage, messageRatings, + branchedConversation, + branchedFrom, + snap, ] ) @@ -491,8 +515,9 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { className="inline-block w-1.5 h-4 bg-foreground-lighter mt-4" /> )} +

- Supabase AI may not always produce correct answers. Double check responses. + The Assistant can make mistakes. Double check responses.

diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.Actions.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.Actions.tsx index d7695343d70f4..e0f05f01e7746 100644 --- a/apps/studio/components/ui/AIAssistantPanel/Message.Actions.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/Message.Actions.tsx @@ -1,5 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod' -import { Pencil, ThumbsDown, ThumbsUp, Trash2 } from 'lucide-react' +import { Check, Copy, Pencil, Split, ThumbsDown, ThumbsUp, Trash2 } from 'lucide-react' import { useEffect, useState, type PropsWithChildren } from 'react' import { useForm } from 'react-hook-form' import { @@ -65,6 +65,42 @@ function MessageActionsDelete({ onClick }: { onClick: () => void }) { } MessageActions.Delete = MessageActionsDelete +function MessageActionsBranch({ onClick }: { onClick: () => void }) { + return ( + } + onClick={onClick} + className="text-foreground-light hover:text-foreground p-1 rounded-sm" + title="Branch in new chat" + aria-label="Branch in new chat" + tooltip={{ content: { side: 'bottom', text: 'Branch in new chat' } }} + /> + ) +} +MessageActions.Branch = MessageActionsBranch + +function MessageActionsCopy({ onClick }: { onClick: (onSuccess: () => void) => void }) { + const [copied, setCopied] = useState(false) + + useEffect(() => { + if (copied) setTimeout(() => setCopied(false), 1000) + }, [copied]) + + return ( + : } + onClick={() => onClick(() => setCopied(true))} + className="text-foreground-light hover:text-foreground p-1 rounded-sm" + title="Copy response" + aria-label="Copy response" + tooltip={{ content: { side: 'bottom', text: 'Copy response' } }} + /> + ) +} +MessageActions.Copy = MessageActionsCopy + function MessageActionsThumbsUp({ onClick, isActive, diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.tsx index 105f53a3b3713..c9a5ef5e6e530 100644 --- a/apps/studio/components/ui/AIAssistantPanel/Message.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/Message.tsx @@ -1,22 +1,32 @@ import { UIMessage as VercelMessage } from '@ai-sdk/react' import { useState } from 'react' import { toast } from 'sonner' -import { cn } from 'ui' +import { cn, copyToClipboard } from 'ui' import { DeleteMessageConfirmModal } from './DeleteMessageConfirmModal' import { MessageActions } from './Message.Actions' import type { AddToolApprovalResponse, MessageInfo } from './Message.Context' import { MessageProvider, useMessageActionsContext, useMessageInfoContext } from './Message.Context' import { MessageDisplay } from './Message.Display' +import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' function AssistantMessage({ message }: { message: VercelMessage }) { - const { id, variant, state, isLastMessage, readOnly, rating, isLoading } = useMessageInfoContext() + const snap = useAiAssistantStateSnapshot() const { onCancelEdit, onRate } = useMessageActionsContext() + const { id, variant, state, isLastMessage, readOnly, rating, isLoading } = useMessageInfoContext() const handleRate = (newRating: 'positive' | 'negative', reason?: string) => { onRate?.(id, newRating, reason) } + const handleCopy = (onSuccess: () => void) => { + const response = message.parts + .filter((x) => x.type === 'text') + .map((x) => x.text) + .join('\n') + copyToClipboard(response, onSuccess) + } + return ( - {!readOnly && isLastMessage && onRate && !isLoading && ( - + {!readOnly && onRate && !isLoading && ( + + handleRate('positive')} isActive={rating === 'positive'} @@ -40,6 +51,7 @@ function AssistantMessage({ message }: { message: VercelMessage }) { isActive={rating === 'negative'} disabled={!!rating} /> + snap.branchChat(id)} /> )} diff --git a/apps/studio/components/ui/BannerStack/Banners/BannerTOSUpdate.tsx b/apps/studio/components/ui/BannerStack/Banners/BannerTOSUpdate.tsx index 1cf1060b98594..c73539ac9298d 100644 --- a/apps/studio/components/ui/BannerStack/Banners/BannerTOSUpdate.tsx +++ b/apps/studio/components/ui/BannerStack/Banners/BannerTOSUpdate.tsx @@ -19,11 +19,6 @@ import { BannerCard } from '../BannerCard' import { useBannerStack } from '../BannerStackProvider' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' -/** - * [Joshen] TOS update takes place from 6th June onwards, can remove from 4th July onwards as - * previously stated in the NoticeBanner - */ - export const BannerTOSUpdate = () => { const { dismissBanner } = useBannerStack() const [, setTOSUpdateAcknowledged] = useLocalStorageQuery( @@ -44,9 +39,9 @@ export const BannerTOSUpdate = () => {
-

We've updated our Terms of Service

+

We're updating our Terms of Service

- Updates define the responsibilities of both you and Supabase in the use of AI. + Our Data Processing Addendum is now built into the Terms, effective August 1, 2026.

@@ -72,37 +67,46 @@ const UpdatedTermsOfServiceDialog = () => { Terms of Service update - We've updated our Terms of Service to better define the responsibilities of both you and - Supabase in the use of AI. + We're updating our Terms of Service, effective August 1, 2026. -

- We've clarified how we use AI in our customer support tooling, introduced guidelines for - the responsible use of AI by our users, and updated our indemnification terms to clarify - the allocation of responsibility for claims arising from AI-generated inputs and - outputs. -

+

What's changing:

-

- Additionally, we've made an explicit commitment that Supabase will never use the data - you submit to the Supabase services to train or improve any AI without your prior - written consent. -

+
    +
  • + Our{' '} + + Data Processing Addendum + {' '} + is now built into the Terms, so all customers get its protections automatically. No + separate signed DPA is needed. +
  • +
  • + Our subprocessor list now lives at{' '} + + supabase.com/legal/customer-resources/subprocessor-list + + , where you can subscribe to receive updates to the list. +
  • +
  • + We've added provisions to our fees section relevant to fraud prevention and the rights + of EU and UK consumers. +
  • +

- The updated Terms (Version 2) will take effect on June 6, 2026. By continuing to use the + The updated Terms (Version 3) take effect on August 1, 2026. By continuing to use the Services after that date, you agree to the updated Terms. You can review the changes{' '} here.

- This notice applies to users on Supabase's standard Terms of Service only. If you are on - an Enterprise plan or with a separately negotiated agreement, your existing terms - continue to govern your use of the Services. + If you have a separate signed subscription agreement or DPA with us, that agreement + continues to govern your use of our Services.

diff --git a/apps/studio/data/documents/dpa-request-mutation.ts b/apps/studio/data/documents/dpa-request-mutation.ts deleted file mode 100644 index 276e382ea38f1..0000000000000 --- a/apps/studio/data/documents/dpa-request-mutation.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { useMutation } from '@tanstack/react-query' -import { toast } from 'sonner' - -import { handleError, post } from '@/data/fetchers' -import type { ResponseError, UseCustomMutationOptions } from '@/types' - -export type DpaRequestVariables = { - recipient_email: string - slug: string -} - -export async function requestDpa({ recipient_email, slug }: DpaRequestVariables) { - const { data, error } = await post(`/platform/organizations/${slug}/documents/dpa` as any, { - // Fix type later - body: { recipient_email }, - }) - if (error) handleError(error) - return data -} - -type DpaRequestData = Awaited> - -export const useDpaRequestMutation = ({ - onSuccess, - onError, - ...options -}: Omit< - UseCustomMutationOptions, - 'mutationFn' -> = {}) => { - return useMutation({ - mutationFn: (vars) => requestDpa(vars), - async onSuccess(data, variables, context) { - await onSuccess?.(data, variables, context) - }, - async onError(data, variables, context) { - if (onError === undefined) { - toast.error(`Failed to request DPA: ${data.message}`) - } else { - onError(data, variables, context) - } - }, - ...options, - }) -} diff --git a/apps/studio/state/ai-assistant-state.tsx b/apps/studio/state/ai-assistant-state.tsx index fc9d188f96982..9e51958f1ab39 100644 --- a/apps/studio/state/ai-assistant-state.tsx +++ b/apps/studio/state/ai-assistant-state.tsx @@ -61,6 +61,7 @@ type ChatSession = { createdAt: Date updatedAt: Date supportMetadata?: SupportChatMetadata + branchedFrom?: { chatId: string; messageId: string } } export type AiAssistantContext = { @@ -424,6 +425,46 @@ export const createAiAssistantState = (): AiAssistantState => { return chatId }, + branchChat: (messageId: string) => { + const sourceChat = state.activeChat + if (!sourceChat) return + + const messageIndex = sourceChat.messages.findIndex((msg) => msg.id === messageId) + if (messageIndex === -1) return + + const branchedMessages = sourceChat.messages + .slice(0, messageIndex + 1) + .map((message) => sanitizeForCloning(message)) + + const chatId = uuidv4() + const newChat: ChatSession = { + id: chatId, + name: `Branch - ${sourceChat.name}`, + messages: branchedMessages, + createdAt: new Date(), + updatedAt: new Date(), + branchedFrom: { chatId: sourceChat.id, messageId }, + } + + state.chats = { + ...state.chats, + [chatId]: newChat, + } + state.activeChatId = chatId + + state.chatInstances[chatId] = ref( + createChatInstance(state, { id: chatId, initialMessages: branchedMessages }) + ) + + const initialAiAssistantData = createInitialAiAssistantData() + state.initialInput = initialAiAssistantData.initialInput + state.sqlSnippets = initialAiAssistantData.sqlSnippets + state.suggestions = initialAiAssistantData.suggestions + state.tables = initialAiAssistantData.tables + + return chatId + }, + setSupportLifecycleStatus: (chatId: string, status: AiSupportStatus) => { const chat = state.chats[chatId] if (!chat?.supportMetadata) return @@ -608,6 +649,7 @@ export type AiAssistantState = AiAssistantData & { Pick > ) => string + branchChat: (messageId: string) => string | undefined setSupportLifecycleStatus: (chatId: string, status: AiSupportStatus) => void selectChat: (id: string) => void deleteChat: (id: string) => void diff --git a/apps/studio/styles/globals.css b/apps/studio/styles/globals.css index 32d0dd3e28209..44e8f55b3c1c1 100644 --- a/apps/studio/styles/globals.css +++ b/apps/studio/styles/globals.css @@ -257,19 +257,12 @@ input.form-control, /* @apply w-full ; */ @apply rounded-md; @apply shadow-xs; - @apply transition-all; @apply text-foreground; @apply border; - @apply focus:shadow-md; - - @apply focus:border-stronger; - @apply focus:ring-border-overlay; - @apply bg-studio; @apply border-strong border; - - @apply outline-hidden; - @apply focus:ring-2 focus:ring-current; + /* Prefer shared focus-ring over legacy green box-shadow / ring-current stacks */ + @apply focus-ring; } .form-group input, @@ -278,26 +271,6 @@ input.form-control, @apply px-4 py-2; } -.form-group input:focus, -.form-group input[type='text']:focus, -.form-group input[type='email']:focus, -.form-group input[type='url']:focus, -.form-group input[type='password']:focus, -.form-group input[type='number']:focus, -.form-group input[type='date']:focus, -.form-group input[type='datetime-local']:focus, -.form-group input[type='month']:focus, -.form-group input[type='search']:focus, -.form-group input[type='tel']:focus, -.form-group input[type='time']:focus, -.form-group input[type='week']:focus, -.form-group input[multiple]:focus, -.form-group textarea:focus, -.form-group select:focus, -.form-group input:focus .form-control:focus { - box-shadow: 0 0 0 2px rgba(62, 207, 142, 0.1); -} - /* icons in date / time inputs */ .dark input[type='date']::-webkit-calendar-picker-indicator, .dark input[type='datetime-local']::-webkit-calendar-picker-indicator, @@ -308,7 +281,7 @@ input.form-control, input.is-invalid { @apply bg-red-100; @apply border border-red-700; - @apply focus:ring-red-500; + @apply focus-ring focus-visible:ring-destructive; @apply placeholder:text-red-600; } diff --git a/apps/www/components/SubprocessorUpdatesForm.tsx b/apps/www/components/SubprocessorUpdatesForm.tsx index 17d094a0a1c22..b89dff34769df 100644 --- a/apps/www/components/SubprocessorUpdatesForm.tsx +++ b/apps/www/components/SubprocessorUpdatesForm.tsx @@ -1,5 +1,6 @@ -import { useState } from 'react' +import { useSendTelemetryEvent } from '~/lib/telemetry' import Link from 'next/link' +import { useState } from 'react' import { Button, Input, Label } from 'ui' const isValidEmail = (email: string): boolean => { @@ -18,6 +19,7 @@ const SubprocessorUpdatesForm = () => { const [email, setEmail] = useState('') const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle') const [errorMessage, setErrorMessage] = useState('') + const sendTelemetryEvent = useSendTelemetryEvent() const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() @@ -48,6 +50,7 @@ const SubprocessorUpdatesForm = () => { } setStatus('success') + sendTelemetryEvent({ action: 'www_subprocessor_updates_subscribed' }) } catch (err: any) { setStatus('error') setErrorMessage(err.message || 'Something went wrong. Please try again.') diff --git a/apps/www/data/Footer.ts b/apps/www/data/Footer.ts index 367dd67321c51..cb5a27a3498cf 100644 --- a/apps/www/data/Footer.ts +++ b/apps/www/data/Footer.ts @@ -104,10 +104,6 @@ const footerData = [ text: 'Security & Compliance', url: '/security', }, - { - text: 'DPA', - url: '/legal/dpa', - }, { text: 'SOC2', url: '/security', diff --git a/apps/www/data/legal/privacy/v3.mdx b/apps/www/data/legal/privacy/v3.mdx index 6e4fcc79f8d9d..41cba2be6321c 100644 --- a/apps/www/data/legal/privacy/v3.mdx +++ b/apps/www/data/legal/privacy/v3.mdx @@ -6,7 +6,7 @@ Thank you for your interest in Supabase, Inc., ("**_Supabase_**," "**_we_**", "* This Privacy Notice explains how we use your personal information when you use the Service, either as an individual customer or when you access the Service through one of our enterprise customers' accounts. We are the data controller of your personal information when we use it as described in this Privacy Notice, meaning that we determine and are responsible for how your personal information is processed. -Our Service allows customers to submit, manage or otherwise use content relating to others, such as end users of applications built and managed through the Service or their employees and contractors (“**_Customer Data_**”). We use such Customer Data primarily as a processor, meaning we process such Customer Data on behalf of and under the instructions of the relevant customer, in accordance with our [data processing addendum](/legal/dpa). This Privacy Notice does not apply to such processing; if you believe your personal information has been included in any Customer Data, we recommend you read the Privacy Notice of the respective customer. +Our Service allows customers to submit, manage or otherwise use content relating to others, such as end users of applications built and managed through the Service or their employees and contractors (“**_Customer Data_**”). We use such Customer Data primarily as a processor, meaning we process such Customer Data on behalf of and under the instructions of the relevant customer, in accordance with our [data processing addendum](/legal/customer-resources/data-processing-addendum). This Privacy Notice does not apply to such processing; if you believe your personal information has been included in any Customer Data, we recommend you read the Privacy Notice of the respective customer. This Privacy Notice sets out how we use personal information. It does not cover our use of information that is not "personal information", "personal information" or similar terms under applicable law. This means that it does not cover our use of aggregated or anonymized information. diff --git a/apps/www/lib/redirects.js b/apps/www/lib/redirects.js index 33e95d0c4ddcb..2ec7c41125d95 100644 --- a/apps/www/lib/redirects.js +++ b/apps/www/lib/redirects.js @@ -1747,6 +1747,11 @@ module.exports = [ source: '/privacy-250528', destination: '/privacy?version=v1', }, + { + permanent: true, + source: '/legal/dpa', + destination: '/legal/customer-resources/data-processing-addendum', + }, { permanent: true, source: '/docs/company/sla', diff --git a/apps/www/pages/legal/customer-resources/subprocessor-list.tsx b/apps/www/pages/legal/customer-resources/subprocessor-list.tsx index 4a06d662cc2f7..71ef1c89ed84b 100644 --- a/apps/www/pages/legal/customer-resources/subprocessor-list.tsx +++ b/apps/www/pages/legal/customer-resources/subprocessor-list.tsx @@ -26,14 +26,10 @@ const meta = { description: 'The list of third-party sub-processors Supabase uses to provide its services.', } -// NOTE: This page is intentionally HIDDEN for now — it is not linked from the Legal Hub -// index (`pages/legal/index.tsx`) or any navigation. It is also marked noindex/nofollow -// so search engines do not index it while it is in draft. Remove `noindex`/`nofollow` and -// add a link from the Legal Hub index when Legal is ready to publish it. export default function SubprocessorListPage() { return ( - + { - const sendTelemetryEvent = useSendTelemetryEvent() - - return ( - - - - } - h1="Data Processing Addendum" - /> - -

- We have a long-standing commitment to customer privacy and data protection. As part of - this commitment, we have prepared a Data Processing Addendum ("DPA"). You can review a - static PDF version of our latest DPA document{' '} - - sendTelemetryEvent({ - action: 'dpa_pdf_opened', - properties: { source: 'www' }, - }) - } - > - here - - . -

- -

- To make the DPA legally binding, you need to sign and complete the details through a - PandaDoc document that we prepare. To get this version of the DPA,{' '} - - request it from the legal documents page - {' '} - of your Supabase dashboard. -

-
- -
- ) -} -export default DPA diff --git a/apps/www/pages/legal/index.tsx b/apps/www/pages/legal/index.tsx index 67d1611cce7f0..f06289feee52d 100644 --- a/apps/www/pages/legal/index.tsx +++ b/apps/www/pages/legal/index.tsx @@ -20,6 +20,11 @@ const sections = [ href: '/legal/customer-resources/data-processing-addendum', type: 'document' as const, }, + { + label: 'Subprocessor List', + href: '/legal/customer-resources/subprocessor-list', + type: 'document' as const, + }, { label: 'Support Policy', href: '/support-policy', diff --git a/apps/www/pages/security.mdx b/apps/www/pages/security.mdx index 802905dd16513..3192ebfa4c663 100644 --- a/apps/www/pages/security.mdx +++ b/apps/www/pages/security.mdx @@ -7,7 +7,7 @@ import { UserGroupIcon, } from '@heroicons/react/outline' import SecurityNewsletterForm from '~/components/SecurityNewsletterForm' -import { Activity, Lock } from 'lucide-react' +import { Activity, FileText, Globe, Lock, Scale } from 'lucide-react' import Layout from '../layouts/Layout' @@ -44,20 +44,19 @@ export const Section = ({ children, icon, img }) => (
-
+
-
+{/* Compliance */} -
}> +
-### Multi-factor Authentication +

+ Compliance +

-Supabase allows users to enable Multi-factor authentication (MFA) on their account. -MFA adds an additional layer of security to your user account, by requiring a second factor to verify your user identity. - -
+
-
}> +
}> ### SOC 2 @@ -71,11 +70,11 @@ Enterprise and Team customers can access our SOC 2 Type 2 report [on the dashboa
-
}> +
}> ### HIPAA -Supabase is HIPAA compliant. You can store Protected Health Information (PHI) on our hosted platform once you enter into a Business Associate Agreement (BAA) with us and fulfill your HIPAA obligations under our [shared responsibility model](/docs/guides/platform/shared-responsibility-model#managing-healthcare-data). +Supabase is HIPAA compliant. You can store Protected Health Information (PHI) on our hosted platform once you enter into a Business Associate Agreement (BAA) with us and fulfill your HIPAA obligations under our [shared responsibility model](/docs/guides/deployment/shared-responsibility-model#managing-healthcare-data). Enterprise and Team customers can request to sign our BAA [on the dashboard](/dashboard/org/_/documents). @@ -84,7 +83,8 @@ Enterprise and Team customers can request to sign our BAA [on the dashboard](/da
-
}> + +
}> ### ISO 27001 @@ -93,7 +93,32 @@ Supabase is ISO 27001 certified. ISO 27001 is an internationally recognized stan Enterprise and Team customers can access our ISO 27001 certificate [on the dashboard](/dashboard/org/_/documents).
-
}> + +
}> + +### GDPR & European Compliance + +Supabase supports GDPR-compliant deployments. Projects hosted in EU regions keep your primary database data in-region, and a Data Processing Agreement (DPA) is available for customers who need a formal data processing contract under GDPR. + +See how [Markprompt uses Supabase for GDPR-compliant deployments](/customers/markprompt). + +
+ + + + + +{/* Data */} + +
+ +

+ Data +

+ +
+ +
}> ### Data Encryption @@ -103,17 +128,17 @@ Sensitive information like access tokens and keys are encrypted at the applicati
-
}> +
}> -### Role-based access control +### Data Residency -Members of organizations in Supabase can be granted access to specific resources. +When you create a project in an AWS region, your Postgres database, Auth service, and Storage objects are hosted in that region. Supabase offers regions across the US, EU, and Asia Pacific. -Read more about [fine grained access controls](/docs/guides/platform/access-control) including Read-Only and Billing-Only access. +See the full list of [available regions](/docs/guides/platform/regions).
-
}> +
}> ### Backups @@ -123,17 +148,47 @@ Point in Time Recovery allows restoring the database to any point in time. Custo
-
}> +
}> -### Payment processing +### Data Processing Agreement -Supabase uses [Stripe](https://stripe.com) to process payments and does not store personal credit card information for any of our customers. +A Data Processing Agreement (DPA) is available for customers who need a formal GDPR data processing contract. [Request or view the DPA](/legal/dpa). + +
+ +
+ +
+ +{/* Configuration */} + +
+ +

+ Configuration +

+ +
+ +
}> -Stripe is a certified PCI Service Provider Level 1, which is the highest level of certification in the payments industry. +### Multi-factor Authentication + +Supabase allows users to enable Multi-factor authentication (MFA) on their account. MFA adds an additional layer of security to your user account, by requiring a second factor to verify your user identity.
-
}> +
}> + +### Role-based access control + +Members of organizations in Supabase can be granted access to specific resources. + +Read more about [fine-grained access controls](/docs/guides/platform/access-control) including Read-Only and Billing-Only access. + +
+ +
}> ### Vulnerability Management @@ -143,7 +198,7 @@ In addition to internal security reviews, we use various tools to scan our code
-
}> +
}> ### DDoS Protection @@ -157,6 +212,42 @@ In addition to protection at the CDN level via Cloudflare, we employ [fail2ban](
+{/* Misc */} + +
+ +

+ Misc +

+ +
+ +
}> + +### Shared Responsibility + +Supabase secures the infrastructure. You secure your application — RLS policies, API keys, and access controls. + +Read the [shared responsibility model](/docs/guides/deployment/shared-responsibility-model). + +
+ +
}> + +### Payment processing + +Supabase uses [Stripe](https://stripe.com) to process payments and does not store personal credit card information for any of our customers. + +Stripe is a certified PCI Service Provider Level 1, which is the highest level of certification in the payments industry. + +
+ +
+ +
+ +
+
diff --git a/apps/www/public/downloads/docs/Supabase+DPA+250314.pdf b/apps/www/public/downloads/docs/Supabase+DPA+250314.pdf deleted file mode 100644 index 04afcb94a5ebf..0000000000000 Binary files a/apps/www/public/downloads/docs/Supabase+DPA+250314.pdf and /dev/null differ diff --git a/apps/www/public/downloads/docs/Supabase+DPA+250805.pdf b/apps/www/public/downloads/docs/Supabase+DPA+250805.pdf deleted file mode 100644 index 1a7482cc1dc4f..0000000000000 Binary files a/apps/www/public/downloads/docs/Supabase+DPA+250805.pdf and /dev/null differ diff --git a/apps/www/public/downloads/docs/Supabase+DPA+260317.pdf b/apps/www/public/downloads/docs/Supabase+DPA+260317.pdf deleted file mode 100644 index a419a11182333..0000000000000 Binary files a/apps/www/public/downloads/docs/Supabase+DPA+260317.pdf and /dev/null differ diff --git a/apps/www/public/downloads/docs/Supabase+DPA+260601.pdf b/apps/www/public/downloads/docs/Supabase+DPA+260601.pdf deleted file mode 100644 index a07284b75d39d..0000000000000 Binary files a/apps/www/public/downloads/docs/Supabase+DPA+260601.pdf and /dev/null differ diff --git a/e2e/studio/utils/test.ts b/e2e/studio/utils/test.ts index 3f1e75de231c1..f51837d40e15f 100644 --- a/e2e/studio/utils/test.ts +++ b/e2e/studio/utils/test.ts @@ -26,7 +26,7 @@ export const test = base.extend({ `table-editor-queue-operations-banner-dismissed-${ref}`, JSON.stringify(true) ) - localStorage.setItem(`terms-of-service-update-2026-06-06`, JSON.stringify(true)) + localStorage.setItem(`terms-of-service-update-2026-08-01`, JSON.stringify(true)) }, ref) await use(page) }, diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index 59f1c1855a1a1..526f36d42dddd 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -71,7 +71,7 @@ export const LOCAL_STORAGE_KEYS = { GITHUB_AUTHORIZATION_STATE: 'supabase-github-authorization-state', // Notice banner keys API_KEYS_FEEDBACK_DISMISSED: (ref: string) => `supabase-api-keys-feedback-dismissed-${ref}`, - TERMS_OF_SERVICE_UPDATE: 'terms-of-service-update-2026-06-06', + TERMS_OF_SERVICE_UPDATE: 'terms-of-service-update-2026-08-01', SUPAVISOR_MAINTENANCE: (ref: string) => `supavisor-maintenance-2026-06-09-${ref}`, REPORT_DATERANGE: 'supabase-report-daterange', PROJECT_PAUSING_STARTED_AT: (ref: string) => `supabase-project-pausing-started-at-${ref}`, diff --git a/packages/common/hooks/useDocsSearch.ts b/packages/common/hooks/useDocsSearch.ts index b64f14b6f908e..631baacef3d11 100644 --- a/packages/common/hooks/useDocsSearch.ts +++ b/packages/common/hooks/useDocsSearch.ts @@ -3,13 +3,15 @@ import { compact, debounce, uniqBy } from 'lodash' import { useCallback, useMemo, useReducer, useRef } from 'react' -import { isFeatureEnabled } from '../enabled-features' - const NUMBER_SOURCES = 2 -const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL -const SUPABASE_ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY -const FUNCTIONS_URL = '/functions/v1/' +// This app's own base path, set only for apps deployed under a path prefix (docs' is '/docs'). +const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? '' +// Public URL of the docs deployment, which hosts the search API routes. +// Same constant apps/studio already uses for cross-linking to docs (see lib/constants/index.ts). +const DOCS_URL = process.env.NEXT_PUBLIC_DOCS_URL || 'https://supabase.com/docs' +// From inside the docs app itself, call our own routes relatively; from studio/www, call docs directly. +const SEARCH_API_BASE = BASE_PATH === '/docs' ? BASE_PATH : DOCS_URL enum PageType { Markdown = 'markdown', @@ -203,18 +205,9 @@ const useDocsSearch = () => { let sourcesLoaded = 0 - const useAlternateSearchIndex = !isFeatureEnabled('search:fullIndex') - - const searchEndpoint = useAlternateSearchIndex ? 'docs_search_fts_nimbus' : 'docs_search_fts' - fetch(`${SUPABASE_URL}/rest/v1/rpc/${searchEndpoint}`, { + fetch(`${SEARCH_API_BASE}/api/search/fts`, { method: 'POST', - headers: { - 'content-type': 'application/json', - ...(SUPABASE_ANON_KEY && { - apikey: SUPABASE_ANON_KEY, - authorization: `Bearer ${SUPABASE_ANON_KEY}`, - }), - }, + headers: { 'content-type': 'application/json' }, body: JSON.stringify({ query: query.trim() }), }) .then((res) => res.json()) @@ -248,9 +241,10 @@ const useDocsSearch = () => { }) }) - fetch(`${SUPABASE_URL}${FUNCTIONS_URL}search-embeddings`, { + fetch(`${SEARCH_API_BASE}/api/search/embeddings`, { method: 'POST', - body: JSON.stringify({ query, useAlternateSearchIndex }), + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ query }), }) .then((response) => response.json()) .then((results) => { diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 16b212fa9489e..fde7ad9de42b7 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -1079,6 +1079,17 @@ export interface WwwEventPageCtaClickedEvent { } } +/** + * User successfully subscribed to subprocessor list update notifications. + * + * @group Events + * @source www + * @page /legal/customer-resources/subprocessor-list + */ +export interface WwwSubprocessorUpdatesSubscribedEvent { + action: 'www_subprocessor_updates_subscribed' +} + /** * User clicked the GitHub button in the homepage header section. The button is hidden in mobile view. * @@ -2101,22 +2112,6 @@ export interface BranchSelectorManageClickedEvent { groups: TelemetryGroups } -/** - * User clicked on a DPA PDF link to open it. - * - * @group Events - * @source www, studio - */ -export interface DpaPdfOpenedEvent { - action: 'dpa_pdf_opened' - properties: { - /** - * The source of the click, e.g. www, studio - */ - source: 'www' | 'studio' - } -} - /** * User clicked on an activity stat in HomeV2. * @@ -2303,18 +2298,6 @@ export interface HomeSectionRowsMovedEvent { groups: TelemetryGroups } -/** - * User clicked the Request DPA button to open the confirmation modal. - * - * @group Events - * @source studio - * @page /dashboard/org/{slug}/documents - */ -export interface DpaRequestButtonClickedEvent { - action: 'dpa_request_button_clicked' - groups: Omit -} - /** * User clicked a document view/download button to access a document. * @@ -2328,7 +2311,7 @@ export interface DocumentViewButtonClickedEvent { /** * The name of the document being viewed, e.g. TIA, SOC2, Standard Security Questionnaire */ - documentName: 'TIA' | 'SOC2' | 'ISO27001' | 'Standard Security Questionnaire' + documentName: 'TIA' | 'SOC2' | 'ISO27001' | 'Standard Security Questionnaire' | 'DPA' } groups: Omit } @@ -3743,6 +3726,7 @@ export type TelemetryEvent = | HomepageProductCardClickedEvent | WwwPricingPlanCtaClickedEvent | WwwEventPageCtaClickedEvent + | WwwSubprocessorUpdatesSubscribedEvent | HomepageGithubButtonClickedEvent | HomepageDiscordButtonClickedEvent | HomepageCustomerStoryCardClickedEvent @@ -3812,7 +3796,6 @@ export type TelemetryEvent = | BranchSelectorBranchClickedEvent | BranchSelectorCreateClickedEvent | BranchSelectorManageClickedEvent - | DpaPdfOpenedEvent | HomeConnectSectionExposedEvent | HomeConnectActionClickedEvent | ConnectSheetOpenedEvent @@ -3822,7 +3805,6 @@ export type TelemetryEvent = | HomeProjectUsageChartClickedEvent | HomeCustomReportBlockAddedEvent | HomeCustomReportBlockRemovedEvent - | DpaRequestButtonClickedEvent | DocumentViewButtonClickedEvent | HipaaRequestButtonClickedEvent | TableCreatedEvent diff --git a/supa-mdx-lint/Rule003Spelling.toml b/supa-mdx-lint/Rule003Spelling.toml index 6e8dd9078577d..6c67ee9777114 100644 --- a/supa-mdx-lint/Rule003Spelling.toml +++ b/supa-mdx-lint/Rule003Spelling.toml @@ -159,6 +159,7 @@ allow_list = [ "UncaughtException", "[Uu]ncomment(ing|ed)?", "[Uu]nlink(ing|s|ed)?", + "[Uu]ntracked", "[Uu]pserts?", "[Uu]ptime", "[Vv]endored",