From 996104a90b6f8c4f7d3b561000ab3133d3ef297c Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Wed, 25 Feb 2026 18:19:45 +0200 Subject: [PATCH 01/13] chore: remove steal fallback from debuggableNavigatorLock (#43172) The SDK now handles orphaned lock recovery via steal internally (supabase-js#2106). Keep the BroadcastChannel observability wrapper for Sentry signals. The steal-based orphaned lock recovery in `debuggableNavigatorLock` (packages/common/gotrue.ts) (introduced in https://github.com/supabase/supabase/pull/39868) is now redundant, supabase-js#2106 handles this natively in the SDK. Removes the `navigator.locks.request({ steal: true })` block while keeping the BroadcastChannel wrapper that sends lock-holder stack traces to Sentry. Related: supabase/supabase-js#2106, supabase/supabase-js#2125 --- packages/common/gotrue.ts | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/packages/common/gotrue.ts b/packages/common/gotrue.ts index cd285c74d2e5a..1c6042ae020fe 100644 --- a/packages/common/gotrue.ts +++ b/packages/common/gotrue.ts @@ -147,32 +147,10 @@ async function debuggableNavigatorLock( } console.error( - `Waited for over 10s to acquire an Auth client lock, will steal the lock to unblock`, + `Waited for over 10s to acquire an Auth client lock`, await navigator.locks.query(), stackException ) - - // quickly steal the lock and release it so that others can acquire it, - // while leaving the code that was holding it to continue running - navigator.locks - .request( - name, - { - steal: true, - }, - async () => { - await new Promise((accept) => { - setTimeout(accept, 0) - }) - - console.error('Lock was stolen and now released', stackException) - } - ) - .catch((e: any) => { - if (captureException) { - captureException(e) - } - }) })() }, 10000) From ec332eb38767a283fcf87f41ceb45c94ec23d11a Mon Sep 17 00:00:00 2001 From: Ana <30495040+ana1337x@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:36:40 -0500 Subject: [PATCH 02/13] Add PrivateLink feature page (#42999) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? A new feature page ## What is the current behavior? N/A ## What is the new behavior? N/A ## Additional context Add any other context or screenshots. Co-authored-by: Ana Mogul --- apps/www/data/features.tsx | 52 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/apps/www/data/features.tsx b/apps/www/data/features.tsx index 3d4240a65207d..6a05b95ac5718 100644 --- a/apps/www/data/features.tsx +++ b/apps/www/data/features.tsx @@ -3007,4 +3007,56 @@ For detailed instructions and best practices, see the [Declarative Schemas docum availableOnSelfHosted: true, }, }, + { + title: 'PrivateLink', + subtitle: 'Secure private network connectivity to your Supabase database.', + description: ` +Supabase PrivateLink provides enterprise-grade private network connectivity between your AWS VPC and your Supabase database using AWS VPC Lattice. This eliminates exposure to the public internet by creating a secure, private connection that keeps your database traffic within the AWS network backbone. + +When enabled, your database connections stay entirely within the AWS network. No public internet exposure. No additional attack surface. From a network perspective, your Supabase database behaves like it's inside your own VPC. + +## Key benefits +1. Enhanced security posture: Database traffic flows through private AWS infrastructure only, minimizing attack vectors by eliminating public exposure. +2. Compliance ready: Meet strict regulatory requirements for private network connectivity in healthcare, finance, and other industries with high compliance requirements. +3. Reduced latency: Connection latency is typically lower than public connections because traffic takes a more direct path through AWS networks. +4. Network isolation: Keep sensitive database connections completely separate from public internet traffic. +5. Simplified architecture: No need to manage complex VPN configurations or additional networking infrastructure. +6. Flexible deployment: Connect through a dedicated PrivateLink endpoint or integrate with existing VPC Lattice Service Networks. + +## How PrivateLink works + +Supabase PrivateLink uses AWS VPC Lattice under the hood. When you enable PrivateLink, Supabase shares a VPC Lattice Resource Configuration with your AWS account. You accept the share and create an endpoint in your VPC. + +Your applications connect to the endpoint using a private DNS name. Traffic flows through AWS infrastructure to your Supabase database. The connection supports both direct Postgres connections and PgBouncer for connection pooling. + +## When to use PrivateLink + +PrivateLink is particularly valuable for: + +- **Highly regulated industries**: Healthcare, finance, and other organizations with high compliance requirements often require private network connectivity to meet these standards. +- **Security-conscious teams**: Minimize your attack surface by disabling public database access entirely once PrivateLink is configured. +- **AWS-native workloads**: If your applications already run on AWS, setting up PrivateLink is straightforward and keeps all traffic within the same cloud provider. +- **Enterprise deployments**: Organizations handling sensitive data that need additional layers of network security. + +## Current considerations + +PrivateLink is currently in Beta with some constraints: + +- **AWS environments required**: This initial release supports connections to AWS VPCs via PrivateLink. Your workloads needs to run in AWS to use PrivateLink. +- **Database connections only**: PrivateLink works for Postgres and PgBouncer connections. It does not cover the Supabase API, Storage, Auth, or Realtime services, which still use public endpoints. +- **Same region required**: Your AWS VPC must be in the same region as your Supabase project. +- **Team or Enterprise plan required**: PrivateLink is available on Team and Enterprise plans. + +By leveraging PrivateLink, you can satisfy stringent compliance requirements, reduce your security attack surface, and ensure your most sensitive database connections never traverse the public internet. +`, + icon: Shield, + products: [ADDITIONAL_PRODUCTS.PLATFORM], + heroImage: '/images/blog/2026/security-retro/privatelink.png', + docsUrl: 'https://supabase.com/docs/guides/platform/privatelink', + slug: 'privatelink', + status: { + stage: PRODUCT_STAGES.BETA, + availableOnSelfHosted: false, + }, + }, ] From 5a01291c23f2e10baa57942334a45c53f212978e Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Wed, 25 Feb 2026 12:18:33 -0500 Subject: [PATCH 03/13] feat(studio): smart incident banner targeting (#43112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature enhancement — smarter incident banner targeting logic ## What is the current behavior? Displaying the incident banner requires toggling a flag or environment variable. Banners are shown to all users regardless of whether their projects are in affected regions or whether the incident affects project creation. ## What is the new behavior? Banner visibility is now driven by `show_banner` metadata from the StatusPage API — no manual flag or env var toggle needed. Per-user targeting is then applied: - Users with projects only see the banner when they have a database in an affected region - Users without projects only see the banner when the incident affects project creation Incident responses are enriched with cache data (`affected_regions`, `affects_project_creation`) fetched from a Supabase table. Visibility logic is extracted into a dedicated hook and pure utility function, backed by unit tests. ## Additional context Resolves FE-2562 --- .../layouts/AppLayout/NoticeBanner.tsx | 12 +- .../layouts/AppLayout/StatusPageBanner.tsx | 18 +- .../AppLayout/StatusPageBanner.utils.test.ts | 225 ++++++++++++++++++ .../AppLayout/StatusPageBanner.utils.ts | 44 ++++ .../useStatusPageBannerVisibility.ts | 61 +++++ apps/studio/data/projects/keys.ts | 3 + .../projects/org-projects-infinite-query.ts | 2 +- apps/studio/lib/api/incident-status.ts | 24 ++ apps/studio/lib/api/supabase-admin.ts | 9 + apps/studio/pages/api/incident-status.ts | 62 ++++- turbo.json | 3 +- 11 files changed, 434 insertions(+), 29 deletions(-) create mode 100644 apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.test.ts create mode 100644 apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.ts create mode 100644 apps/studio/components/layouts/AppLayout/useStatusPageBannerVisibility.ts create mode 100644 apps/studio/lib/api/supabase-admin.ts diff --git a/apps/studio/components/layouts/AppLayout/NoticeBanner.tsx b/apps/studio/components/layouts/AppLayout/NoticeBanner.tsx index 3b9cd6c2c1df4..6b6c61a501a05 100644 --- a/apps/studio/components/layouts/AppLayout/NoticeBanner.tsx +++ b/apps/studio/components/layouts/AppLayout/NoticeBanner.tsx @@ -1,10 +1,10 @@ +import { LOCAL_STORAGE_KEYS } from 'common' import { useRouter } from 'next/router' +import { TimestampInfo } from 'ui-patterns' +import { HeaderBanner } from '@/components/interfaces/Organization/HeaderBanner' +import { InlineLink } from '@/components/ui/InlineLink' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' -import { LOCAL_STORAGE_KEYS } from 'common' -import { HeaderBanner } from 'components/interfaces/Organization/HeaderBanner' -import { InlineLink } from 'components/ui/InlineLink' -import { TimestampInfo } from 'ui-patterns' /** * Used to display urgent notices that apply for all users, such as maintenance windows. @@ -12,7 +12,7 @@ import { TimestampInfo } from 'ui-patterns' export const NoticeBanner = () => { const router = useRouter() - const [bannerAcknowledged, setBannerAcknowledge, { isSuccess }] = useLocalStorageQuery( + const [bannerAcknowledged, setBannerAcknowledged, { isSuccess }] = useLocalStorageQuery( LOCAL_STORAGE_KEYS.MAINTENANCE_WINDOW_BANNER, false ) @@ -38,7 +38,7 @@ export const NoticeBanner = () => { } - onDismiss={() => setBannerAcknowledge(true)} + onDismiss={() => setBannerAcknowledged(true)} /> ) } diff --git a/apps/studio/components/layouts/AppLayout/StatusPageBanner.tsx b/apps/studio/components/layouts/AppLayout/StatusPageBanner.tsx index 1254546603c62..f43caee3d6ce6 100644 --- a/apps/studio/components/layouts/AppLayout/StatusPageBanner.tsx +++ b/apps/studio/components/layouts/AppLayout/StatusPageBanner.tsx @@ -1,7 +1,6 @@ -import { useFlag } from 'common' - import { HeaderBanner } from '@/components/interfaces/Organization/HeaderBanner' import { InlineLink } from '@/components/ui/InlineLink' +import { useStatusPageBannerVisibility } from './useStatusPageBannerVisibility' const BANNER_DESCRIPTION = ( <> @@ -13,18 +12,9 @@ const BANNER_DESCRIPTION = ( * Used to display ongoing incidents */ export const StatusPageBanner = () => { - const showIncidentBanner = - useFlag('ongoingIncident') || process.env.NEXT_PUBLIC_ONGOING_INCIDENT === 'true' + const banner = useStatusPageBannerVisibility() - if (showIncidentBanner) { - return ( - - ) - } + if (!banner) return null - return null + return } diff --git a/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.test.ts b/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.test.ts new file mode 100644 index 0000000000000..5b2618d773947 --- /dev/null +++ b/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } from 'vitest' + +import { shouldShowBanner } from './StatusPageBanner.utils' + +const noCache = { cache: null } as const +const noRestrictions = { + cache: { affected_regions: null, affects_project_creation: false }, +} +const affectsCreation = { + cache: { affected_regions: null, affects_project_creation: true }, +} +const usEast1Only = { + cache: { affected_regions: ['us-east-1'], affects_project_creation: false }, +} +const usEast1AndCreation = { + cache: { affected_regions: ['us-east-1'], affects_project_creation: true }, +} + +describe('shouldShowBanner', () => { + describe('no incidents', () => { + it('does not show when there are no incidents', () => { + expect( + shouldShowBanner({ incidents: [], hasProjects: true, userRegions: new Set(['us-east-1']) }) + ).toBe(false) + }) + }) + + describe('user has no projects', () => { + it('does not show when cache is absent', () => { + expect( + shouldShowBanner({ incidents: [noCache], hasProjects: false, userRegions: new Set() }) + ).toBe(false) + }) + + it('does not show when affects_project_creation is false', () => { + expect( + shouldShowBanner({ + incidents: [noRestrictions], + hasProjects: false, + userRegions: new Set(), + }) + ).toBe(false) + }) + + it('shows when affects_project_creation is true and no region restriction', () => { + expect( + shouldShowBanner({ + incidents: [affectsCreation], + hasProjects: false, + userRegions: new Set(), + }) + ).toBe(true) + }) + + it('shows when affects_project_creation is true even with a region restriction', () => { + expect( + shouldShowBanner({ + incidents: [usEast1AndCreation], + hasProjects: false, + userRegions: new Set(), + }) + ).toBe(true) + }) + }) + + describe('user has projects, no region restriction', () => { + it('shows when cache is absent', () => { + expect( + shouldShowBanner({ + incidents: [noCache], + hasProjects: true, + userRegions: new Set(['us-east-1']), + }) + ).toBe(true) + }) + + it('shows when affected_regions is null', () => { + expect( + shouldShowBanner({ + incidents: [noRestrictions], + hasProjects: true, + userRegions: new Set(['us-east-1']), + }) + ).toBe(true) + }) + + it('shows when affected_regions is an empty array', () => { + expect( + shouldShowBanner({ + incidents: [{ cache: { affected_regions: [], affects_project_creation: false } }], + hasProjects: true, + userRegions: new Set(['us-east-1']), + }) + ).toBe(true) + }) + }) + + describe('user has projects, with region restriction', () => { + it('shows when user has a primary database in an affected region', () => { + expect( + shouldShowBanner({ + incidents: [usEast1Only], + hasProjects: true, + userRegions: new Set(['us-east-1']), + }) + ).toBe(true) + }) + + it('shows when user has a read replica in an affected region', () => { + expect( + shouldShowBanner({ + incidents: [ + { cache: { affected_regions: ['eu-west-1'], affects_project_creation: false } }, + ], + hasProjects: true, + userRegions: new Set(['us-east-1', 'eu-west-1']), + }) + ).toBe(true) + }) + + it('shows when one of multiple affected regions matches', () => { + expect( + shouldShowBanner({ + incidents: [ + { + cache: { + affected_regions: ['us-east-1', 'ap-southeast-1'], + affects_project_creation: false, + }, + }, + ], + hasProjects: true, + userRegions: new Set(['ap-southeast-1']), + }) + ).toBe(true) + }) + + it('does not show when user has no databases in any affected region', () => { + expect( + shouldShowBanner({ + incidents: [usEast1Only], + hasProjects: true, + userRegions: new Set(['eu-west-1', 'ap-southeast-1']), + }) + ).toBe(false) + }) + + it('does not show when user has projects but no databases in affected region, even with affects_project_creation', () => { + expect( + shouldShowBanner({ + incidents: [usEast1AndCreation], + hasProjects: true, + userRegions: new Set(['eu-west-1']), + }) + ).toBe(false) + }) + }) + + describe('multiple incidents', () => { + it('shows when at least one incident matches even if others do not', () => { + expect( + shouldShowBanner({ + incidents: [usEast1Only, noRestrictions], + hasProjects: true, + userRegions: new Set(['eu-west-1']), + }) + ).toBe(true) + }) + + it('does not show when no incident matches', () => { + expect( + shouldShowBanner({ + incidents: [usEast1Only, usEast1AndCreation], + hasProjects: true, + userRegions: new Set(['eu-west-1']), + }) + ).toBe(false) + }) + + it('shows when any incident matches for a no-project user via affects_project_creation', () => { + expect( + shouldShowBanner({ + incidents: [usEast1Only, affectsCreation], + hasProjects: false, + userRegions: new Set(), + }) + ).toBe(true) + }) + }) + + describe('hasUnknownRegions', () => { + it('shows when regions are unknown and incident has a region restriction', () => { + expect( + shouldShowBanner({ + incidents: [usEast1Only], + hasProjects: true, + userRegions: new Set(), + hasUnknownRegions: true, + }) + ).toBe(true) + }) + + it('still applies no-projects check even when regions are unknown', () => { + expect( + shouldShowBanner({ + incidents: [usEast1Only], + hasProjects: false, + userRegions: new Set(), + hasUnknownRegions: true, + }) + ).toBe(false) + }) + + it('still shows for affects_project_creation with no projects even when regions are unknown', () => { + expect( + shouldShowBanner({ + incidents: [usEast1AndCreation], + hasProjects: false, + userRegions: new Set(), + hasUnknownRegions: true, + }) + ).toBe(true) + }) + }) +}) diff --git a/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.ts b/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.ts new file mode 100644 index 0000000000000..d8fcf5f8f7b80 --- /dev/null +++ b/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.ts @@ -0,0 +1,44 @@ +import type { IncidentCache } from 'lib/api/incident-status' + +type BannerIncident = { cache?: IncidentCache | null } + +/** + * Determines whether the incident status banner should be shown to a given user, + * given all active incidents and the user's project state. + * + * Returns true if any incident matches the user's context. + * + * @param incidents - Active incidents from the incident-status endpoint + * @param hasProjects - Whether the user has any projects at all + * @param userRegions - Deduplicated set of regions of all databases (primary and read replicas) owned by the user + * @param hasUnknownRegions - True when region data is incomplete (org has >100 projects). + * When true, the region check is skipped and a match is assumed. + */ +export function shouldShowBanner({ + incidents, + hasProjects, + userRegions, + hasUnknownRegions = false, +}: { + incidents: Array + hasProjects: boolean + userRegions: Set + hasUnknownRegions?: boolean +}): boolean { + return incidents.some((incident) => { + const affectedRegions = incident.cache?.affected_regions ?? [] + const affectsProjectCreation = incident.cache?.affects_project_creation ?? false + + // Users with no projects only see the banner if the incident affects project creation + if (!hasProjects) return affectsProjectCreation + + // User has projects: if no region restriction, always show + if (affectedRegions.length === 0) return true + + // Region data is incomplete — assume the user has a database in an affected region + if (hasUnknownRegions) return true + + // Region restriction: only show if the user has a database in an affected region + return affectedRegions.some((region) => userRegions.has(region)) + }) +} diff --git a/apps/studio/components/layouts/AppLayout/useStatusPageBannerVisibility.ts b/apps/studio/components/layouts/AppLayout/useStatusPageBannerVisibility.ts new file mode 100644 index 0000000000000..8975cba3b9a13 --- /dev/null +++ b/apps/studio/components/layouts/AppLayout/useStatusPageBannerVisibility.ts @@ -0,0 +1,61 @@ +import { useQueries } from '@tanstack/react-query' +import { useFlag } from 'common' + +import { shouldShowBanner } from './StatusPageBanner.utils' +import { useOrganizationsQuery } from '@/data/organizations/organizations-query' +import { useIncidentStatusQuery } from '@/data/platform/incident-status-query' +import { projectKeys } from '@/data/projects/keys' +import { + getOrganizationProjects, + type OrgProject, +} from '@/data/projects/org-projects-infinite-query' + +export type StatusPageBannerData = { title: string } + +export function useStatusPageBannerVisibility(): StatusPageBannerData | null { + const showIncidentBannerOverride = + useFlag('ongoingIncident') || process.env.NEXT_PUBLIC_ONGOING_INCIDENT === 'true' + + const { data: allStatusPageEvents } = useIncidentStatusQuery() + const { incidents = [] } = allStatusPageEvents ?? {} + + const hasActiveIncidents = incidents.length > 0 + + const { data: organizations } = useOrganizationsQuery({ + enabled: !showIncidentBannerOverride && hasActiveIncidents, + }) + + const orgProjectsQueries = useQueries({ + queries: (organizations ?? []).map((org) => ({ + queryKey: projectKeys.bannerProjectsByOrg(org.slug), + queryFn: () => getOrganizationProjects({ slug: org.slug, limit: 100 }), + staleTime: 5 * 60 * 1000, + enabled: !showIncidentBannerOverride && hasActiveIncidents, + })), + }) + + const isProjectsFetched = + organizations !== undefined && + (organizations.length === 0 || orgProjectsQueries.every((q) => q.isFetched)) + + const allProjects = orgProjectsQueries.flatMap((q) => q.data?.projects ?? []) + const hasProjects = allProjects.length > 0 + const userRegions = new Set( + allProjects.flatMap((project: OrgProject) => project.databases.map((db) => db.region)) + ) + const hasUnknownRegions = orgProjectsQueries.some( + (q) => q.isError || (q.data !== undefined && q.data.pagination.count > q.data.projects.length) + ) + + if (showIncidentBannerOverride) return { title: 'We are investigating a technical issue' } + + if (!hasActiveIncidents || !isProjectsFetched) return null + + if (!shouldShowBanner({ incidents, hasProjects, userRegions, hasUnknownRegions })) return null + + return { + title: hasProjects + ? 'We are investigating a technical issue' + : 'Project creation may be impacted in some regions', + } +} diff --git a/apps/studio/data/projects/keys.ts b/apps/studio/data/projects/keys.ts index 0684b180478e9..810e41baf17a7 100644 --- a/apps/studio/data/projects/keys.ts +++ b/apps/studio/data/projects/keys.ts @@ -36,4 +36,7 @@ export const projectKeys = { ['projects', projectRef, 'clone-backups'] as const, listCloneStatus: (projectRef: string | undefined) => ['projects', projectRef, 'clone-status'] as const, + + // Banner-specific: first-page snapshot used by the status page banner hook + bannerProjectsByOrg: (slug: string) => ['banner', 'org-projects', slug] as const, } diff --git a/apps/studio/data/projects/org-projects-infinite-query.ts b/apps/studio/data/projects/org-projects-infinite-query.ts index d13b328d006b1..02825fb27389d 100644 --- a/apps/studio/data/projects/org-projects-infinite-query.ts +++ b/apps/studio/data/projects/org-projects-infinite-query.ts @@ -24,7 +24,7 @@ interface GetOrgProjectsInfiniteVariables { export type OrgProjectsResponse = components['schemas']['OrganizationProjectsResponse'] export type OrgProject = OrgProjectsResponse['projects'][number] -async function getOrganizationProjects( +export async function getOrganizationProjects( { slug, limit = DEFAULT_LIMIT, diff --git a/apps/studio/lib/api/incident-status.ts b/apps/studio/lib/api/incident-status.ts index b552e27ead45b..7b19ee53c40f8 100644 --- a/apps/studio/lib/api/incident-status.ts +++ b/apps/studio/lib/api/incident-status.ts @@ -3,12 +3,25 @@ import z from 'zod' import { IS_PLATFORM } from 'common' import { InternalServerError } from 'lib/api/apiHelpers' +export type IncidentCache = { + affected_regions: Array | null + affects_project_creation: boolean +} + +export type IncidentMetadata = { + dashboard_metadata?: { + show_banner?: boolean + } +} + export type IncidentInfo = { id: string name: string status: string impact: string active_since: string + metadata: IncidentMetadata + cache?: IncidentCache | null } const STATUSPAGE_API_URL = 'https://api.statuspage.io/v1' @@ -27,6 +40,16 @@ const StatusPageIncidentsSchema = z.array( created_at: z.string(), scheduled_for: z.string().nullable(), impact: z.string(), + metadata: z + .object({ + dashboard_metadata: z + .object({ + show_banner: z.boolean().optional(), + }) + .optional(), + }) + .optional() + .default({}), }) ) @@ -114,5 +137,6 @@ export async function getActiveIncidents(): Promise { status: incident.status, impact: incident.impact, active_since: incident.scheduled_for ?? incident.created_at, + metadata: incident.metadata, })) } diff --git a/apps/studio/lib/api/supabase-admin.ts b/apps/studio/lib/api/supabase-admin.ts new file mode 100644 index 0000000000000..e6a19f7008b7b --- /dev/null +++ b/apps/studio/lib/api/supabase-admin.ts @@ -0,0 +1,9 @@ +import { createClient } from '@supabase/supabase-js' + +/** + * Creates a Supabase client using the secret key. + * For use in server-side API routes only. + */ +export function createAdminClient() { + return createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.LIVE_SUPABASE_SECRET_KEY!) +} diff --git a/apps/studio/pages/api/incident-status.ts b/apps/studio/pages/api/incident-status.ts index e925a92e6a332..c075bbdd38ad4 100644 --- a/apps/studio/pages/api/incident-status.ts +++ b/apps/studio/pages/api/incident-status.ts @@ -1,8 +1,13 @@ +import { IS_PLATFORM } from 'common' import { NextApiRequest, NextApiResponse } from 'next' -import { IS_PLATFORM } from 'common' -import { InternalServerError } from 'lib/api/apiHelpers' -import { getActiveIncidents, type IncidentInfo } from 'lib/api/incident-status' +import { InternalServerError } from '@/lib/api/apiHelpers' +import { + getActiveIncidents, + type IncidentCache, + type IncidentInfo, +} from '@/lib/api/incident-status' +import { createAdminClient } from '@/lib/api/supabase-admin' /** * Cache on browser for 5 minutes @@ -11,11 +16,41 @@ import { getActiveIncidents, type IncidentInfo } from 'lib/api/incident-status' */ const CACHE_CONTROL_SETTINGS = 'public, max-age=300, s-maxage=300, stale-while-revalidate=60' +async function fetchIncidentCache(incidentIds: Array): Promise> { + const cacheMap = new Map() + + if (incidentIds.length === 0) return cacheMap + + const supabase = createAdminClient() + + try { + const { data, error } = await supabase + .from('incident_status_cache') + .select('incident_id, affected_regions, affects_project_creation') + .in('incident_id', incidentIds) + + if (error) { + console.error('Failed to fetch incident_status_cache: %O', error) + return cacheMap + } + + for (const row of data ?? []) { + cacheMap.set(row.incident_id, { + affected_regions: row.affected_regions ?? null, + affects_project_creation: row.affects_project_creation, + }) + } + } catch (error) { + console.error('Unexpected error fetching incident_status_cache: %O', error) + } + + return cacheMap +} + // Default export needed by Next.js convention -// eslint-disable-next-line no-restricted-exports export default async function handler( req: NextApiRequest, - res: NextApiResponse + res: NextApiResponse | { error: string }> ) { if (!IS_PLATFORM) { return res.status(404).end() @@ -34,11 +69,24 @@ export default async function handler( } try { - const incidents = await getActiveIncidents() + const allIncidents = await getActiveIncidents() + + const bannerIncidents = allIncidents.filter( + (incident) => + incident.impact !== 'maintenance' && + incident.metadata?.dashboard_metadata?.show_banner === true + ) + + const cacheMap = await fetchIncidentCache(bannerIncidents.map((i) => i.id)) + + const enrichedIncidents = bannerIncidents.map((incident) => ({ + ...incident, + cache: cacheMap.get(incident.id) ?? null, + })) res.setHeader('Cache-Control', CACHE_CONTROL_SETTINGS) - return res.status(200).json(incidents) + return res.status(200).json(enrichedIncidents) } catch (error) { if (error instanceof InternalServerError) { console.error('Failed to fetch active StatusPage incidents: %O', { diff --git a/turbo.json b/turbo.json index c8026da1181a8..789b14f1c5979 100644 --- a/turbo.json +++ b/turbo.json @@ -108,7 +108,8 @@ "AI_NORMAL_MODEL", "SUPPORT_SUPABASE_SECRET_KEY", "STATUSPAGE_API_KEY", - "STATUSPAGE_PAGE_ID" + "STATUSPAGE_PAGE_ID", + "LIVE_SUPABASE_SECRET_KEY" ], "passThroughEnv": [ "CURRENT_CLI_VERSION", From 75ec7c6e6b36442cb440f96a3e561e7ad0d4b77f Mon Sep 17 00:00:00 2001 From: Sean Oliver <882952+seanoliver@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:24:32 -0800 Subject: [PATCH 04/13] feat(growth): re-land first-referrer cookie attribution with fixed middleware matchers (#43153) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Re-lands the first-referrer cookie feature from #42768 (reverted in #43129) with middleware matcher fixes that prevent Studio traffic interference. **Tracks:** [GROWTH-651](https://linear.app/supabase/issue/GROWTH-651) ## What changed New shared module in `packages/common/first-referrer-cookie.ts` that handles stamping and parsing a first-referrer cookie (referrer, UTMs, click IDs, landing URL). Each app's middleware calls `stampFirstReferrerCookie` on the edge response — www and docs are the primary entry points, Studio is a fallback for direct visits with UTMs. On the telemetry side, `handlePageTelemetry` now takes an options object instead of positional args, reads the cookie on initial pageview, and overrides the referrer if the cookie captured an external source but the current referrer is internal (i.e., the user navigated cross-app). Also sends `first_referrer_cookie_present`/`consumed` properties so we can observe the handoff in PostHog. The docs middleware matcher was broadened from `/reference/:path*` to all docs pages so we stamp cookies site-wide, not just on reference paths. ## Root cause of original revert Two layers: 1. **Matcher gap**: www middleware ran on `/dashboard/*` traffic in prod due to Vercel Multi-Zone architecture (www is the gateway for `supabase.com`, proxying `/dashboard` → Studio, `/docs` → Docs). Middleware runs *before* rewrites, so www middleware executed on all proxied traffic. 2. **`_next/data` interception**: The matcher didn't exclude `_next/data` paths. Client-side navigation in Next.js fetches JSON via `/_next/data/...` — middleware intercepted these, returned `NextResponse.next()` with cookie mutations (which processes through the middleware response pipeline), and this interfered with the JSON responses, causing full page reloads in the SQL editor. ## How this PR fixes it | Fix | Detail | |---|---| | Exclude `_next/data` | All three matchers (`www`, `docs`, `studio`) exclude `_next/data` via negative lookahead | | Exclude `dashboard` + `docs` from www | www middleware no longer runs on proxied app traffic | | `/api/` path guard in Studio | Broadened matcher requires explicit path check for API route filtering | | `NextResponse.next()` semantics | Cookie stamping only happens on matched paths; unmatched paths never enter middleware | ### `NextResponse.next()` vs `undefined` nuance Returning an explicit `NextResponse.next()` with cookie mutations processes through Next.js's middleware response pipeline (headers are merged, cookies are set). Returning `undefined` (i.e. the request never matches the matcher) lets Next.js handle the request completely untouched. The matcher exclusions ensure `_next/data` and proxied app paths never enter middleware at all. ## Testing - ✅ 22 unit tests for shared cookie utilities (all pass) - ✅ Studio prod build succeeds, middleware recognized as `ƒ Proxy (Middleware)` - ✅ Playwright validation: client-side navigation works across 3 page transitions, `_next/data` requests return 200 OK without middleware interception, no full-page reloads - ❌ www/docs SSG builds require platform backend services (expected — same as master) --- apps/docs/middleware.ts | 44 ++- apps/studio/proxy.ts | 36 +- apps/www/middleware.ts | 18 + packages/common/first-referrer-cookie.test.ts | 229 +++++++++++++ packages/common/first-referrer-cookie.ts | 307 ++++++++++++++++++ packages/common/index.tsx | 1 + packages/common/telemetry.tsx | 138 ++++++-- 7 files changed, 715 insertions(+), 58 deletions(-) create mode 100644 apps/www/middleware.ts create mode 100644 packages/common/first-referrer-cookie.test.ts create mode 100644 packages/common/first-referrer-cookie.ts diff --git a/apps/docs/middleware.ts b/apps/docs/middleware.ts index ea55aafa52efc..02b6499dd9c3b 100644 --- a/apps/docs/middleware.ts +++ b/apps/docs/middleware.ts @@ -1,17 +1,23 @@ -import { isbot } from 'isbot' -import { NextResponse, type NextRequest } from 'next/server' - import { clientSdkIds } from '~/content/navigation.references' import { BASE_PATH } from '~/lib/constants' +import { stampFirstReferrerCookie } from 'common/first-referrer-cookie' +import { isbot } from 'isbot' +import { NextResponse, type NextRequest } from 'next/server' const REFERENCE_PATH = `${BASE_PATH ?? ''}/reference` export function middleware(request: NextRequest) { const url = new URL(request.url) + + // Non-reference paths: just handle the first-referrer cookie and pass through if (!url.pathname.startsWith(REFERENCE_PATH)) { - return NextResponse.next() + const response = NextResponse.next() + stampFirstReferrerCookie(request, response) + return response } + // Reference paths: existing rewrite logic with cookie stamping on every response + if (isbot(request.headers.get('user-agent'))) { let [, lib, maybeVersion, ...slug] = url.pathname.replace(REFERENCE_PATH, '').split('/') @@ -24,7 +30,9 @@ export function middleware(request: NextRequest) { if (slug.length > 0) { const rewriteUrl = new URL(url) rewriteUrl.pathname = (BASE_PATH ?? '') + '/api/crawlers' - return NextResponse.rewrite(rewriteUrl) + const response = NextResponse.rewrite(rewriteUrl) + stampFirstReferrerCookie(request, response) + return response } } } @@ -33,28 +41,42 @@ export function middleware(request: NextRequest) { if (lib === 'cli') { const rewritePath = [REFERENCE_PATH, 'cli'].join('/') - return NextResponse.rewrite(new URL(rewritePath, request.url)) + const response = NextResponse.rewrite(new URL(rewritePath, request.url)) + stampFirstReferrerCookie(request, response) + return response } if (lib === 'api') { const rewritePath = [REFERENCE_PATH, 'api'].join('/') - return NextResponse.rewrite(new URL(rewritePath, request.url)) + const response = NextResponse.rewrite(new URL(rewritePath, request.url)) + stampFirstReferrerCookie(request, response) + return response } if (lib?.startsWith('self-hosting-')) { const rewritePath = [REFERENCE_PATH, lib].join('/') - return NextResponse.rewrite(new URL(rewritePath, request.url)) + const response = NextResponse.rewrite(new URL(rewritePath, request.url)) + stampFirstReferrerCookie(request, response) + return response } if (clientSdkIds.includes(lib)) { const version = /v\d+/.test(maybeVersion) ? maybeVersion : null const rewritePath = [REFERENCE_PATH, lib, version].filter(Boolean).join('/') - return NextResponse.rewrite(new URL(rewritePath, request.url)) + const response = NextResponse.rewrite(new URL(rewritePath, request.url)) + stampFirstReferrerCookie(request, response) + return response } - return NextResponse.next() + const response = NextResponse.next() + stampFirstReferrerCookie(request, response) + return response } export const config = { - matcher: '/reference/:path*', + matcher: [ + // Broadened from `/reference/:path*` to stamp first-referrer cookies on all + // docs pages, not just reference paths. Excludes Next.js internals and static files. + '/((?!api|_next/static|_next/image|_next/data|favicon.ico|__nextjs).*)', + ], } diff --git a/apps/studio/proxy.ts b/apps/studio/proxy.ts index 27c941fb1c1c5..e24258312d65a 100644 --- a/apps/studio/proxy.ts +++ b/apps/studio/proxy.ts @@ -1,10 +1,8 @@ +import { stampFirstReferrerCookie } from 'common/first-referrer-cookie' import { IS_PLATFORM } from 'lib/constants' +import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' -export const config = { - matcher: '/api/:function*', -} - // [Joshen] Return 404 for all next.js API endpoints EXCEPT the ones we use in hosted: const HOSTED_SUPPORTED_API_URLS = [ '/ai/sql/generate-v4', @@ -29,13 +27,27 @@ const HOSTED_SUPPORTED_API_URLS = [ ] export function proxy(request: NextRequest) { - if ( - IS_PLATFORM && - !HOSTED_SUPPORTED_API_URLS.some((url) => request.nextUrl.pathname.endsWith(url)) - ) { - return Response.json( - { success: false, message: 'Endpoint not supported on hosted' }, - { status: 404 } - ) + // API route filtering for hosted platform + if (request.nextUrl.pathname.startsWith('/api/')) { + if ( + IS_PLATFORM && + !HOSTED_SUPPORTED_API_URLS.some((url) => request.nextUrl.pathname.endsWith(url)) + ) { + return Response.json( + { success: false, message: 'Endpoint not supported on hosted' }, + { status: 404 } + ) + } } + + // Belt & suspenders: stamp first-referrer cookie for direct Studio visits. + // Primary stamping happens in www/docs middleware; this catches edge cases + // like bookmarked Studio URLs with UTMs or direct-to-Studio paid traffic. + const response = NextResponse.next() + stampFirstReferrerCookie(request, response) + return response +} + +export const config = { + matcher: ['/((?!_next/static|_next/image|_next/data|favicon.ico|__nextjs).*)'], } diff --git a/apps/www/middleware.ts b/apps/www/middleware.ts new file mode 100644 index 0000000000000..27c61a108c141 --- /dev/null +++ b/apps/www/middleware.ts @@ -0,0 +1,18 @@ +import { stampFirstReferrerCookie } from 'common/first-referrer-cookie' +import { NextResponse, type NextRequest } from 'next/server' + +export function middleware(request: NextRequest) { + const response = NextResponse.next() + stampFirstReferrerCookie(request, response) + return response +} + +export const config = { + matcher: [ + // Match all paths except Next.js internals, static files, and proxied app paths. + // - _next/data: client-side navigation JSON fetches (MUST exclude to prevent full page reloads) + // - dashboard: Studio app (proxied via multi-zone, has its own cookie stamping in proxy.ts) + // - docs: Docs app (proxied via multi-zone in prod, has its own middleware for cookie stamping) + '/((?!api|_next/static|_next/image|_next/data|dashboard|docs|favicon.ico|__nextjs).*)', + ], +} diff --git a/packages/common/first-referrer-cookie.test.ts b/packages/common/first-referrer-cookie.test.ts new file mode 100644 index 0000000000000..ce308e2d24487 --- /dev/null +++ b/packages/common/first-referrer-cookie.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from 'vitest' + +import { + buildFirstReferrerData, + FIRST_REFERRER_COOKIE_NAME, + hasPaidSignals, + isExternalReferrer, + parseFirstReferrerCookie, + serializeFirstReferrerCookie, + shouldRefreshCookie, +} from './first-referrer-cookie' + +describe('first-referrer-cookie', () => { + describe('isExternalReferrer', () => { + it('returns false for supabase domains', () => { + expect(isExternalReferrer('https://supabase.com')).toBe(false) + expect(isExternalReferrer('https://www.supabase.com')).toBe(false) + expect(isExternalReferrer('https://docs.supabase.com')).toBe(false) + }) + + it('returns true for external domains', () => { + expect(isExternalReferrer('https://google.com')).toBe(true) + expect(isExternalReferrer('https://chatgpt.com')).toBe(true) + }) + + it('returns true for http:// referrers', () => { + expect(isExternalReferrer('http://google.com')).toBe(true) + expect(isExternalReferrer('http://example.org/page')).toBe(true) + }) + + it('returns false for invalid values', () => { + expect(isExternalReferrer('')).toBe(false) + expect(isExternalReferrer('not-a-url')).toBe(false) + }) + }) + + describe('buildFirstReferrerData', () => { + it('handles malformed landing URL gracefully', () => { + const data = buildFirstReferrerData({ + referrer: 'https://google.com', + landingUrl: 'not-a-valid-url', + }) + + expect(data.referrer).toBe('https://google.com') + expect(data.landing_url).toBe('not-a-valid-url') + expect(data.utms).toEqual({}) + expect(data.click_ids).toEqual({}) + }) + + it('extracts utm and click-id params from landing url', () => { + const data = buildFirstReferrerData({ + referrer: 'https://www.google.com/', + landingUrl: + 'https://supabase.com/pricing?utm_source=google&utm_medium=cpc&utm_campaign=test&gclid=abc123&msclkid=xyz456', + }) + + expect(data.referrer).toBe('https://www.google.com/') + expect(data.landing_url).toBe( + 'https://supabase.com/pricing?utm_source=google&utm_medium=cpc&utm_campaign=test&gclid=abc123&msclkid=xyz456' + ) + + expect(data.utms).toEqual({ + utm_source: 'google', + utm_medium: 'cpc', + utm_campaign: 'test', + }) + + expect(data.click_ids).toEqual({ + gclid: 'abc123', + msclkid: 'xyz456', + }) + }) + }) + + describe('serialize / parse', () => { + it('round-trips valid cookie payloads', () => { + const input = buildFirstReferrerData({ + referrer: 'https://www.google.com/', + landingUrl: 'https://supabase.com/pricing?utm_source=google', + }) + + const encoded = serializeFirstReferrerCookie(input) + const parsed = parseFirstReferrerCookie(`${FIRST_REFERRER_COOKIE_NAME}=${encoded}`) + + expect(parsed).toEqual(input) + }) + + it('returns null for empty string', () => { + expect(parseFirstReferrerCookie('')).toBeNull() + }) + + it('parses cookie from header with multiple cookies', () => { + const input = buildFirstReferrerData({ + referrer: 'https://google.com/', + landingUrl: 'https://supabase.com/', + }) + const encoded = serializeFirstReferrerCookie(input) + const header = `session=abc123; ${FIRST_REFERRER_COOKIE_NAME}=${encoded}; theme=dark` + + expect(parseFirstReferrerCookie(header)).toEqual(input) + }) + + it('returns null for malformed json', () => { + expect(parseFirstReferrerCookie(`${FIRST_REFERRER_COOKIE_NAME}=%7Bnot-json`)).toBeNull() + }) + + it('returns null for invalid payload shape', () => { + const encoded = encodeURIComponent(JSON.stringify({ foo: 'bar' })) + expect(parseFirstReferrerCookie(`${FIRST_REFERRER_COOKIE_NAME}=${encoded}`)).toBeNull() + }) + + it('drops non-string values in utms/click_ids', () => { + const encoded = encodeURIComponent( + JSON.stringify({ + referrer: 'https://www.google.com/', + landing_url: 'https://supabase.com/pricing', + utms: { utm_source: 'google', utm_medium: 123 }, + click_ids: { gclid: 'abc', msclkid: null }, + ts: 123, + }) + ) + + const parsed = parseFirstReferrerCookie(`${FIRST_REFERRER_COOKIE_NAME}=${encoded}`) + + expect(parsed).toEqual({ + referrer: 'https://www.google.com/', + landing_url: 'https://supabase.com/pricing', + utms: { utm_source: 'google' }, + click_ids: { gclid: 'abc' }, + ts: 123, + }) + }) + }) + + describe('hasPaidSignals', () => { + it('detects click IDs', () => { + expect(hasPaidSignals(new URL('https://supabase.com/?gclid=abc'))).toBe(true) + expect(hasPaidSignals(new URL('https://supabase.com/?fbclid=abc'))).toBe(true) + expect(hasPaidSignals(new URL('https://supabase.com/?msclkid=abc'))).toBe(true) + expect(hasPaidSignals(new URL('https://supabase.com/?gbraid=abc'))).toBe(true) + expect(hasPaidSignals(new URL('https://supabase.com/?wbraid=abc'))).toBe(true) + expect(hasPaidSignals(new URL('https://supabase.com/?rdt_cid=abc'))).toBe(true) + expect(hasPaidSignals(new URL('https://supabase.com/?ttclid=abc'))).toBe(true) + expect(hasPaidSignals(new URL('https://supabase.com/?twclid=abc'))).toBe(true) + expect(hasPaidSignals(new URL('https://supabase.com/?li_fat_id=abc'))).toBe(true) + }) + + it('detects paid utm_medium values', () => { + expect(hasPaidSignals(new URL('https://supabase.com/?utm_medium=cpc'))).toBe(true) + expect(hasPaidSignals(new URL('https://supabase.com/?utm_medium=ppc'))).toBe(true) + expect(hasPaidSignals(new URL('https://supabase.com/?utm_medium=paid_search'))).toBe(true) + expect(hasPaidSignals(new URL('https://supabase.com/?utm_medium=paidsocial'))).toBe(true) + expect(hasPaidSignals(new URL('https://supabase.com/?utm_medium=paid_social'))).toBe(true) + expect(hasPaidSignals(new URL('https://supabase.com/?utm_medium=display'))).toBe(true) + }) + + it('is case-insensitive for utm_medium', () => { + expect(hasPaidSignals(new URL('https://supabase.com/?utm_medium=CPC'))).toBe(true) + expect(hasPaidSignals(new URL('https://supabase.com/?utm_medium=Paid_Search'))).toBe(true) + }) + + it('returns false for organic traffic', () => { + expect(hasPaidSignals(new URL('https://supabase.com/'))).toBe(false) + expect(hasPaidSignals(new URL('https://supabase.com/?utm_source=google'))).toBe(false) + expect(hasPaidSignals(new URL('https://supabase.com/?utm_medium=email'))).toBe(false) + expect(hasPaidSignals(new URL('https://supabase.com/?utm_medium=organic'))).toBe(false) + }) + }) + + describe('shouldRefreshCookie', () => { + it('stamps when no cookie and external referrer', () => { + expect( + shouldRefreshCookie(false, { + referrer: 'https://google.com', + url: 'https://supabase.com/', + }) + ).toEqual({ stamp: true }) + }) + + it('skips when no cookie and internal referrer', () => { + expect( + shouldRefreshCookie(false, { + referrer: 'https://supabase.com/docs', + url: 'https://supabase.com/dashboard', + }) + ).toEqual({ stamp: false }) + }) + + it('skips when cookie exists and no paid signals', () => { + expect( + shouldRefreshCookie(true, { + referrer: 'https://google.com', + url: 'https://supabase.com/', + }) + ).toEqual({ stamp: false }) + }) + + it('refreshes when cookie exists but URL has paid signals', () => { + expect( + shouldRefreshCookie(true, { + referrer: 'https://google.com', + url: 'https://supabase.com/?gclid=abc123', + }) + ).toEqual({ stamp: true }) + + expect( + shouldRefreshCookie(true, { + referrer: 'https://google.com', + url: 'https://supabase.com/?utm_medium=cpc&utm_source=google', + }) + ).toEqual({ stamp: true }) + }) + + it('skips when no cookie and no referrer (direct navigation)', () => { + expect(shouldRefreshCookie(false, { referrer: '', url: 'https://supabase.com/' })).toEqual({ + stamp: false, + }) + }) + + it('handles malformed URL gracefully', () => { + expect( + shouldRefreshCookie(true, { + referrer: 'https://google.com', + url: 'not-a-valid-url', + }) + ).toEqual({ stamp: false }) + }) + }) +}) diff --git a/packages/common/first-referrer-cookie.ts b/packages/common/first-referrer-cookie.ts new file mode 100644 index 0000000000000..cfa7d6b7131f8 --- /dev/null +++ b/packages/common/first-referrer-cookie.ts @@ -0,0 +1,307 @@ +/** + * Shared utilities for the cross-app first-referrer handoff cookie. + * + * The `_sb_first_referrer` cookie is written by edge middleware on `apps/www`, + * `apps/docs`, and `apps/studio` when a user arrives from an + * external source. Studio reads it on the first telemetry pageview to recover + * external attribution context that would otherwise be lost at the app boundary. + * + * The cookie is normally write-once (365-day TTL, domain=supabase.com), but is + * refreshed when a returning visitor arrives with paid traffic signals (click IDs + * or paid UTM medium values) to ensure paid attribution overrides stale organic data. + */ + +// --------------------------------------------------------------------------- +// Structural types for Next.js middleware request/response +// --------------------------------------------------------------------------- +// Using structural interfaces instead of importing NextRequest/NextResponse +// avoids version conflicts when different apps pin different Next.js versions +// (e.g. studio on Next 15, docs/www on Next 16). + +interface MiddlewareRequest { + headers: { get(name: string): string | null } + cookies: { has(name: string): boolean } + url: string + nextUrl: { hostname: string } +} + +interface MiddlewareResponse { + cookies: { + set( + name: string, + value: string, + options?: { + path?: string + sameSite?: 'lax' | 'strict' | 'none' + secure?: boolean + domain?: string + maxAge?: number + } + ): void + } +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +export const FIRST_REFERRER_COOKIE_NAME = '_sb_first_referrer' + +/** 365 days in seconds */ +export const FIRST_REFERRER_COOKIE_MAX_AGE = 365 * 24 * 60 * 60 + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface FirstReferrerData { + /** The external referrer URL (e.g. https://www.google.com/) */ + referrer: string + /** The landing URL on our site when the external referrer was captured */ + landing_url: string + /** UTM params parsed from the landing URL (e.g. utm_source, utm_medium) */ + utms: Record + /** Ad-network click IDs parsed from the landing URL */ + click_ids: Record + /** Unix timestamp (ms) when the cookie was written */ + ts: number +} + +// --------------------------------------------------------------------------- +// Referrer classification +// --------------------------------------------------------------------------- + +/** + * Returns true if the referrer URL points to an external (non-Supabase) domain. + * Handles malformed URLs gracefully by returning false. + */ +export function isExternalReferrer(referrer: string): boolean { + if (!referrer) return false + try { + const hostname = new URL(referrer).hostname + return hostname !== 'supabase.com' && !hostname.endsWith('.supabase.com') + } catch { + return false + } +} + +// --------------------------------------------------------------------------- +// UTM + click-ID extraction +// --------------------------------------------------------------------------- + +const UTM_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'] as const + +const CLICK_ID_KEYS = [ + 'gclid', // Google Ads + 'gbraid', // Google Ads (iOS) + 'wbraid', // Google Ads (iOS) + 'msclkid', // Microsoft Ads (Bing) + 'fbclid', // Meta (Facebook/Instagram) + 'rdt_cid', // Reddit Ads + 'ttclid', // TikTok Ads + 'twclid', // X Ads (Twitter) + 'li_fat_id', // LinkedIn Ads +] as const + +function pickParams( + searchParams: URLSearchParams, + keys: readonly string[] +): Record { + const result: Record = {} + for (const key of keys) { + const value = searchParams.get(key) + if (value) { + result[key] = value + } + } + return result +} + +function toStringRecord(value: unknown): Record { + if (!value || typeof value !== 'object') return {} + + return Object.fromEntries( + Object.entries(value as Record).filter( + ([key, v]) => typeof key === 'string' && typeof v === 'string' + ) + ) as Record +} + +// --------------------------------------------------------------------------- +// Build cookie payload from a request (edge-compatible) +// --------------------------------------------------------------------------- + +/** + * Build a `FirstReferrerData` payload from raw request values. + * Intended for use in Next.js middleware where `document` is not available. + */ +export function buildFirstReferrerData({ + referrer, + landingUrl, +}: { + referrer: string + landingUrl: string +}): FirstReferrerData { + let utms: Record = {} + let click_ids: Record = {} + + try { + const url = new URL(landingUrl) + utms = pickParams(url.searchParams, UTM_KEYS) + click_ids = pickParams(url.searchParams, CLICK_ID_KEYS) + } catch { + // If landing URL is malformed, just skip param extraction + } + + return { + referrer, + landing_url: landingUrl, + utms, + click_ids, + ts: Date.now(), + } +} + +// --------------------------------------------------------------------------- +// Serialize / parse +// --------------------------------------------------------------------------- + +export function serializeFirstReferrerCookie(data: FirstReferrerData): string { + return encodeURIComponent(JSON.stringify(data)) +} + +// --------------------------------------------------------------------------- +// Paid-signal detection +// --------------------------------------------------------------------------- + +const PAID_UTM_MEDIUMS = new Set([ + 'cpc', + 'ppc', + 'paid_search', + 'paidsocial', + 'paid_social', + 'display', +]) + +/** + * Returns true if the URL contains ad-network click IDs or paid UTM medium values. + * These indicate the user arrived via a paid campaign, which should override + * stale organic attribution. + */ +export function hasPaidSignals(url: URL): boolean { + for (const key of CLICK_ID_KEYS) { + if (url.searchParams.has(key)) return true + } + const medium = url.searchParams.get('utm_medium')?.toLowerCase() + return medium !== undefined && PAID_UTM_MEDIUMS.has(medium) +} + +/** + * Decides whether the first-referrer cookie should be (re-)stamped. + * + * - No cookie + external referrer → stamp (first visit attribution) + * - Cookie exists + paid signals in URL → stamp (paid traffic refresh) + * - Otherwise → skip + */ +export function shouldRefreshCookie( + existingCookie: boolean, + request: { referrer: string; url: string } +): { stamp: boolean } { + if (!existingCookie) { + return { stamp: isExternalReferrer(request.referrer) } + } + + try { + const url = new URL(request.url) + return { stamp: hasPaidSignals(url) } + } catch { + return { stamp: false } + } +} + +// --------------------------------------------------------------------------- +// Middleware helper — shared across apps/www, apps/docs, and apps/studio +// --------------------------------------------------------------------------- + +/** + * Stamp the first-referrer cookie on a Next.js middleware response if the + * request warrants it. This is the single entry point for all app middleware + * files — call it with the incoming request and outgoing response. + * + * On *.supabase.com the cookie is set with `domain=supabase.com` so it's + * readable across all subdomains (www, docs, studio). On other hosts + * (localhost, preview deploys) the domain is left unset so the browser + * stores a host-only cookie instead of rejecting an invalid domain. + */ +export function stampFirstReferrerCookie(request: MiddlewareRequest, response: MiddlewareResponse): void { + const referrer = request.headers.get('referer') ?? '' + + const { stamp } = shouldRefreshCookie(request.cookies.has(FIRST_REFERRER_COOKIE_NAME), { + referrer, + url: request.url, + }) + + if (!stamp) return + + const data = buildFirstReferrerData({ + referrer, + landingUrl: request.url, + }) + + response.cookies.set(FIRST_REFERRER_COOKIE_NAME, serializeFirstReferrerCookie(data), { + path: '/', + sameSite: 'lax', + ...(request.nextUrl.hostname === 'supabase.com' || + request.nextUrl.hostname.endsWith('.supabase.com') + ? { domain: 'supabase.com', secure: true } + : {}), + maxAge: FIRST_REFERRER_COOKIE_MAX_AGE, + }) +} + +// --------------------------------------------------------------------------- +// Parse cookie from document.cookie header (client-side) +// --------------------------------------------------------------------------- + +export function parseFirstReferrerCookie(cookieHeader: string): FirstReferrerData | null { + try { + const cookies = cookieHeader.split(';') + const match = cookies + .map((c) => c.trim()) + .find((c) => c.startsWith(`${FIRST_REFERRER_COOKIE_NAME}=`)) + + if (!match) return null + + const value = match.slice(`${FIRST_REFERRER_COOKIE_NAME}=`.length) + const parsed = JSON.parse(decodeURIComponent(value)) as unknown + + if (!parsed || typeof parsed !== 'object') return null + + const parsedRecord = parsed as Record + const referrer = parsedRecord.referrer + const landingUrl = parsedRecord.landing_url + + if (typeof referrer !== 'string' || typeof landingUrl !== 'string') { + return null + } + + const utmsRaw = parsedRecord.utms + const clickIdsRaw = parsedRecord.click_ids + const tsRaw = parsedRecord.ts + + const utms = toStringRecord(utmsRaw) + const click_ids = toStringRecord(clickIdsRaw) + + const ts = typeof tsRaw === 'number' && Number.isFinite(tsRaw) ? tsRaw : Date.now() + + return { + referrer, + landing_url: landingUrl, + utms, + click_ids, + ts, + } + } catch { + return null + } +} diff --git a/packages/common/index.tsx b/packages/common/index.tsx index 87b60f4e6f996..ee8ebe133070c 100644 --- a/packages/common/index.tsx +++ b/packages/common/index.tsx @@ -10,5 +10,6 @@ export * from './helpers' export * from './hooks' export * from './MetaFavicons/pages-router' export * from './Providers' +export * from './first-referrer-cookie' export * from './telemetry' export * from './telemetry-utils' diff --git a/packages/common/telemetry.tsx b/packages/common/telemetry.tsx index 29c416c8bd90d..36b3ea4ee011c 100644 --- a/packages/common/telemetry.tsx +++ b/packages/common/telemetry.tsx @@ -12,6 +12,8 @@ import { hasConsented } from './consent-state' import { IS_PLATFORM, IS_PROD, LOCAL_STORAGE_KEYS } from './constants' import { useFeatureFlags } from './feature-flags' import { post } from './fetchWrappers' +import type { FirstReferrerData } from './first-referrer-cookie' +import { isExternalReferrer, parseFirstReferrerCookie } from './first-referrer-cookie' import { ensurePlatformSuffix, isBrowser } from './helpers' import { useParams, useTelemetryCookie } from './hooks' import { posthogClient, type ClientTelemetryEvent } from './posthog-client' @@ -96,25 +98,25 @@ function getFirstTouchAttributionProps(telemetryData: SharedTelemetryData) { } } -function isExternalReferrer(referrer: string) { - try { - const hostname = new URL(referrer).hostname - return hostname !== 'supabase.com' && !hostname.endsWith('.supabase.com') - } catch { - return false - } +interface HandlePageTelemetryOptions { + apiUrl: string + pathname?: string + featureFlags?: Record + slug?: string + ref?: string + telemetryDataOverride?: SharedTelemetryData + firstReferrerData?: FirstReferrerData | null } -function handlePageTelemetry( - API_URL: string, - pathname?: string, - featureFlags?: { - [key: string]: unknown - }, - slug?: string, - ref?: string, - telemetryDataOverride?: SharedTelemetryData -) { +function handlePageTelemetry({ + apiUrl: API_URL, + pathname, + featureFlags, + slug, + ref, + telemetryDataOverride, + firstReferrerData, +}: HandlePageTelemetryOptions) { // Send to PostHog client-side (only in browser) if (typeof window !== 'undefined') { const livePageData = getSharedTelemetryData(pathname) @@ -133,10 +135,51 @@ function handlePageTelemetry( referrer: shouldUseCookieReferrer ? cookieReferrer! : liveReferrer, }, } - : livePageData - const firstTouchAttributionProps = telemetryDataOverride - ? getFirstTouchAttributionProps(telemetryDataOverride) - : {} + : { ...livePageData, ph: { ...livePageData.ph } } + const firstTouchAttributionProps: Record = { + ...(telemetryDataOverride ? getFirstTouchAttributionProps(telemetryDataOverride) : {}), + } + + // --- First-referrer edge cookie handoff --- + // If the edge cookie has external context and the current referrer is internal, + // override the referrer so PostHog gets the real acquisition source. + const firstReferrerCookiePresent = Boolean(firstReferrerData) + let firstReferrerCookieConsumed = false + + if ( + firstReferrerData && + isExternalReferrer(firstReferrerData.referrer) && + !isExternalReferrer(pageData.ph.referrer) + ) { + pageData.ph.referrer = firstReferrerData.referrer + firstReferrerCookieConsumed = true + + // Prefer attribution context captured at the external entry point. + const { utms, click_ids, landing_url } = firstReferrerData + + Object.entries(utms).forEach(([key, value]) => { + const phKey = key.startsWith('utm_') ? `$${key}` : key + firstTouchAttributionProps[phKey] = value + }) + + Object.entries(click_ids).forEach(([key, value]) => { + firstTouchAttributionProps[key] = value + }) + + try { + const url = new URL(landing_url) + firstTouchAttributionProps.first_touch_url = url.href + firstTouchAttributionProps.first_touch_pathname = url.pathname + + if (url.search) { + firstTouchAttributionProps.first_touch_search = url.search + } else { + delete firstTouchAttributionProps.first_touch_search + } + } catch { + // Skip if landing URL is malformed + } + } const $referrer = pageData.ph.referrer const $referring_domain = (() => { @@ -170,6 +213,13 @@ function handlePageTelemetry( ...Object.fromEntries( Object.entries(featureFlags || {}).map(([k, v]) => [`$feature/${k}`, v]) ), + // Measurement properties for handoff observability + // Only included on the initial pageview (when firstReferrerData is explicitly + // passed as null or a value — subsequent pageviews leave it as undefined) + ...(firstReferrerData !== undefined && { + first_referrer_cookie_present: firstReferrerCookiePresent, + first_referrer_cookie_consumed: firstReferrerCookieConsumed, + }), }) } @@ -234,13 +284,13 @@ export const PageTelemetry = ({ const sendPageTelemetry = useCallback(() => { if (!(enabled && hasAcceptedConsent)) return Promise.resolve() - return handlePageTelemetry( - API_URL, - pathnameRef.current, - featureFlagsRef.current, + return handlePageTelemetry({ + apiUrl: API_URL, + pathname: pathnameRef.current, + featureFlags: featureFlagsRef.current, slug, - ref - ).catch((e) => { + ref, + }).catch((e) => { console.error('Problem sending telemetry page:', e) }) }, [API_URL, enabled, hasAcceptedConsent, slug, ref]) @@ -283,6 +333,9 @@ export const PageTelemetry = ({ hasAcceptedConsent && !hasSentInitialPageTelemetryRef.current ) { + // Read the edge-set first-referrer cookie (cross-app handoff) + const firstReferrerData = parseFirstReferrerCookie(document.cookie) + const cookies = document.cookie.split(';') const telemetryCookieValue = cookies .map((cookie) => cookie.trim()) @@ -294,24 +347,39 @@ export const PageTelemetry = ({ const telemetryData = JSON.parse( decodeURIComponent(telemetryCookieValue) ) as SharedTelemetryData - handlePageTelemetry( - API_URL, - pathnameRef.current, - featureFlagsRef.current, + handlePageTelemetry({ + apiUrl: API_URL, + pathname: pathnameRef.current, + featureFlags: featureFlagsRef.current, slug, ref, - telemetryData - ) + telemetryDataOverride: telemetryData, + firstReferrerData, + }) } catch (error) { if (!IS_PROD) { console.warn('Invalid telemetry cookie data:', error) } - handlePageTelemetry(API_URL, pathnameRef.current, featureFlagsRef.current, slug, ref) + handlePageTelemetry({ + apiUrl: API_URL, + pathname: pathnameRef.current, + featureFlags: featureFlagsRef.current, + slug, + ref, + firstReferrerData, + }) } finally { clearTelemetryDataCookie() } } else { - handlePageTelemetry(API_URL, pathnameRef.current, featureFlagsRef.current, slug, ref) + handlePageTelemetry({ + apiUrl: API_URL, + pathname: pathnameRef.current, + featureFlags: featureFlagsRef.current, + slug, + ref, + firstReferrerData, + }) } hasSentInitialPageTelemetryRef.current = true From 785625c92f06474847dc695fe1b65a99c8b16d75 Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:42:17 +0100 Subject: [PATCH 05/13] fix: add compact formatting to Y axis numbers in db charts (#43170) In the db charts, numbers in the Y axis are not formatted, sometimes these get too big and get cut-off. ## before (in this example it is not cutoff because the number is not big enough) CleanShot 2026-02-25 at 11 20 35@2x ## after CleanShot 2026-02-25 at 11 21 07@2x --- .../components/ui/Charts/Charts.utils.tsx | 15 +++++++++ apps/studio/data/reports/database-charts.ts | 7 ++-- .../components/ui/Charts/Charts.utils.test.ts | 32 +++++++++++++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/apps/studio/components/ui/Charts/Charts.utils.tsx b/apps/studio/components/ui/Charts/Charts.utils.tsx index 152fe2466c7d7..42cdbb3b97f44 100644 --- a/apps/studio/components/ui/Charts/Charts.utils.tsx +++ b/apps/studio/components/ui/Charts/Charts.utils.tsx @@ -58,6 +58,21 @@ export const precisionFormatter = (num: number, precision: number): string => { } } +/** + * Formats a number compactly for Y-axis ticks by abbreviating large values. + * Prevents long numbers like 1,000,000 from overflowing the Y-axis width. + * + * @example + * compactNumberFormatter(999) // "999" + * compactNumberFormatter(1000) // "1K" + * compactNumberFormatter(1500) // "1.5K" + * compactNumberFormatter(1000000) // "1M" + * compactNumberFormatter(2500000) // "2.5M" + */ +export const compactNumberFormatter = (num: number): string => { + return new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(num) +} + /** * Formats a percentage, trimming decimals at 100. * diff --git a/apps/studio/data/reports/database-charts.ts b/apps/studio/data/reports/database-charts.ts index 77e096470ebb3..0b51863d73196 100644 --- a/apps/studio/data/reports/database-charts.ts +++ b/apps/studio/data/reports/database-charts.ts @@ -1,8 +1,9 @@ -import { numberFormatter } from 'components/ui/Charts/Charts.utils' +import { compactNumberFormatter, numberFormatter } from 'components/ui/Charts/Charts.utils' import { ReportAttributes } from 'components/ui/Charts/ComposedChart.utils' import { DOCS_URL } from 'lib/constants' import { formatBytes } from 'lib/helpers' import type { Organization } from 'types' + import { DiskAttributesData } from '../config/disk-attributes-query' import { MaxConnectionsData } from '../database/max-connections-query' import { Project } from '../projects/project-detail-query' @@ -148,8 +149,8 @@ export const getReportAttributesV2: ( showGrid: true, showMaxValue: true, YAxisProps: { - width: 35, - tickFormatter: (value: any) => numberFormatter(value, 0), + width: 50, + tickFormatter: (value: any) => compactNumberFormatter(value), }, defaultChartStyle: 'bar', attributes: [ diff --git a/apps/studio/tests/components/ui/Charts/Charts.utils.test.ts b/apps/studio/tests/components/ui/Charts/Charts.utils.test.ts index 135d445f93bbe..f059ba53b24dc 100644 --- a/apps/studio/tests/components/ui/Charts/Charts.utils.test.ts +++ b/apps/studio/tests/components/ui/Charts/Charts.utils.test.ts @@ -1,5 +1,6 @@ import { renderHook } from '@testing-library/react' import { + compactNumberFormatter, formatPercentage, isFloat, numberFormatter, @@ -105,6 +106,37 @@ describe('formatPercentage', () => { }) }) +describe('compactNumberFormatter', () => { + it('returns the number as-is below 1000', () => { + expect(compactNumberFormatter(0)).toBe('0') + expect(compactNumberFormatter(1)).toBe('1') + expect(compactNumberFormatter(999)).toBe('999') + }) + + it('formats thousands with K suffix', () => { + expect(compactNumberFormatter(1000)).toBe('1K') + expect(compactNumberFormatter(1500)).toBe('1.5K') + expect(compactNumberFormatter(64000)).toBe('64K') + expect(compactNumberFormatter(999999)).toBe('1M') // rounds up + }) + + it('formats millions with M suffix', () => { + expect(compactNumberFormatter(1_000_000)).toBe('1M') + expect(compactNumberFormatter(1_500_000)).toBe('1.5M') + expect(compactNumberFormatter(2_500_000)).toBe('2.5M') + }) + + it('formats billions with B suffix', () => { + expect(compactNumberFormatter(1_000_000_000)).toBe('1B') + expect(compactNumberFormatter(2_500_000_000)).toBe('2.5B') + }) + + it('handles negative numbers', () => { + expect(compactNumberFormatter(-1000)).toBe('-1K') + expect(compactNumberFormatter(-1_500_000)).toBe('-1.5M') + }) +}) + test('useStacked', () => { const { result } = renderHook(() => useStacked({ From f9f002140198e9f14b7daa92303ebcefaf59030b Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Wed, 25 Feb 2026 19:33:57 +0100 Subject: [PATCH 06/13] fix iops chart max value (#43182) The Y axis domain/range was being calculated incorrectly. Testing this is a bit painful. You need to: - using tweak-extension or similar overwrite the API response for the IOPS chart and add a really big value - reload the page - the Y axis should adapt to that really big value even if it goes over the disk max iops See screenshots below ## before - big data point wouldn't change the chart range - Y Axis max is 6k even tho we have a data point over 20k CleanShot 2026-02-25 at 18 47 14@2x ## after - the Y axis shows the correct range CleanShot 2026-02-25 at 18 47 57@2x --- .../components/ui/Charts/Charts.utils.test.ts | 174 ++++++++++++++++++ .../components/ui/Charts/Charts.utils.tsx | 60 ++++++ .../components/ui/Charts/ComposedChart.tsx | 34 +++- apps/studio/data/reports/database-charts.ts | 2 +- 4 files changed, 263 insertions(+), 7 deletions(-) create mode 100644 apps/studio/components/ui/Charts/Charts.utils.test.ts diff --git a/apps/studio/components/ui/Charts/Charts.utils.test.ts b/apps/studio/components/ui/Charts/Charts.utils.test.ts new file mode 100644 index 0000000000000..7ff56eddc3700 --- /dev/null +++ b/apps/studio/components/ui/Charts/Charts.utils.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest' + +import { computeYAxisDomain } from './Charts.utils' + +const IOPS_DATA = [ + { timestamp: 1, disk_iops_write: 1200, disk_iops_read: 24203, disk_iops_max: 25000 }, + { timestamp: 2, disk_iops_write: 400, disk_iops_read: 3200, disk_iops_max: 25000 }, + { timestamp: 3, disk_iops_write: 100, disk_iops_read: 900, disk_iops_max: 25000 }, +] + +const IOPS_VISIBLE = ['disk_iops_write', 'disk_iops_read'] + +describe('computeYAxisDomain', () => { + describe('percentage charts with max line hidden', () => { + it('returns [0, yMaxFromVisible] to zoom in on the data', () => { + expect( + computeYAxisDomain({ + isPercentage: true, + showMaxValue: false, + yMaxFromVisible: 75, + maxAttributeKey: 'cpu_usage_max', + showMaxLine: false, + data: [{ cpu_busy: 75, cpu_usage_max: 100 }], + visibleAttributeNames: ['cpu_busy'], + }) + ).toEqual([0, 75]) + }) + + it('still zooms in even when a maxAttributeKey is present', () => { + expect( + computeYAxisDomain({ + isPercentage: true, + showMaxValue: false, + yMaxFromVisible: 60, + maxAttributeKey: 'cpu_usage_max', + showMaxLine: true, + data: [{ cpu_busy: 60, cpu_usage_max: 100 }], + visibleAttributeNames: ['cpu_busy'], + }) + ).toEqual([0, 60]) + }) + }) + + describe('no max reference line', () => { + it('returns auto when maxAttributeKey is undefined', () => { + expect( + computeYAxisDomain({ + isPercentage: false, + showMaxValue: false, + yMaxFromVisible: 5000, + maxAttributeKey: undefined, + showMaxLine: false, + data: IOPS_DATA, + visibleAttributeNames: IOPS_VISIBLE, + }) + ).toEqual(['auto', 'auto']) + }) + + it('returns auto when showMaxLine is false', () => { + expect( + computeYAxisDomain({ + isPercentage: false, + showMaxValue: true, + yMaxFromVisible: 5000, + maxAttributeKey: 'disk_iops_max', + showMaxLine: false, + data: IOPS_DATA, + visibleAttributeNames: IOPS_VISIBLE, + }) + ).toEqual(['auto', 'auto']) + }) + }) + + describe('max reference line not yet loaded (value is 0)', () => { + it('returns auto when diskConfig has not loaded and reference line value is 0', () => { + const dataWithZeroMax = IOPS_DATA.map((p) => ({ ...p, disk_iops_max: 0 })) + expect( + computeYAxisDomain({ + isPercentage: false, + showMaxValue: true, + yMaxFromVisible: 5000, + maxAttributeKey: 'disk_iops_max', + showMaxLine: true, + data: dataWithZeroMax, + visibleAttributeNames: IOPS_VISIBLE, + }) + ).toEqual(['auto', 'auto']) + }) + }) + + describe('explicit domain with reference line', () => { + it('uses the reference line value when bars stay below it', () => { + // All stacked bar totals (1000, 500) are well below maxRefValue (25000) + expect( + computeYAxisDomain({ + isPercentage: false, + showMaxValue: true, + yMaxFromVisible: 800, + maxAttributeKey: 'disk_iops_max', + showMaxLine: true, + data: [ + { disk_iops_write: 400, disk_iops_read: 600, disk_iops_max: 25000 }, + { disk_iops_write: 200, disk_iops_read: 300, disk_iops_max: 25000 }, + ], + visibleAttributeNames: IOPS_VISIBLE, + }) + ).toEqual([0, 25000]) + }) + + it('uses the stacked bar total when it exceeds the reference line', () => { + // Stacked total at first point: 24203 + 1200 = 25403 > 25000 + expect( + computeYAxisDomain({ + isPercentage: false, + showMaxValue: true, + yMaxFromVisible: 24203, + maxAttributeKey: 'disk_iops_max', + showMaxLine: true, + data: IOPS_DATA, + visibleAttributeNames: IOPS_VISIBLE, + }) + ).toEqual([0, 25403]) + }) + + it('domain min is always 0', () => { + const [min] = computeYAxisDomain({ + isPercentage: false, + showMaxValue: true, + yMaxFromVisible: 100, + maxAttributeKey: 'disk_iops_max', + showMaxLine: true, + data: IOPS_DATA, + visibleAttributeNames: IOPS_VISIBLE, + }) as [number, number] + expect(min).toBe(0) + }) + + it('works for database connections chart (single bar series, no stacking)', () => { + const data = [ + { pg_stat_database_num_backends: 45, max_db_connections: 60 }, + { pg_stat_database_num_backends: 52, max_db_connections: 60 }, + ] + expect( + computeYAxisDomain({ + isPercentage: false, + showMaxValue: true, + yMaxFromVisible: 52, + maxAttributeKey: 'max_db_connections', + showMaxLine: true, + data, + visibleAttributeNames: ['pg_stat_database_num_backends'], + }) + ).toEqual([0, 60]) + }) + + it('handles non-numeric values in data gracefully', () => { + const data = [ + { disk_iops_write: 'bad', disk_iops_read: null, disk_iops_max: 25000 }, + { disk_iops_write: 500, disk_iops_read: 1000, disk_iops_max: 25000 }, + ] + expect( + computeYAxisDomain({ + isPercentage: false, + showMaxValue: true, + yMaxFromVisible: 1000, + maxAttributeKey: 'disk_iops_max', + showMaxLine: true, + data: data as Record[], + visibleAttributeNames: IOPS_VISIBLE, + }) + ).toEqual([0, 25000]) + }) + }) +}) diff --git a/apps/studio/components/ui/Charts/Charts.utils.tsx b/apps/studio/components/ui/Charts/Charts.utils.tsx index 42cdbb3b97f44..c3645cca40e7f 100644 --- a/apps/studio/components/ui/Charts/Charts.utils.tsx +++ b/apps/studio/components/ui/Charts/Charts.utils.tsx @@ -114,6 +114,66 @@ export const timestampFormatter = ( return dayjs(value).format(format) } +/** + * Computes the Y-axis domain for a ComposedChart that may contain stacked Bar components + * and an optional max-value reference Line. + * + * Recharts' `['auto', 'auto']` domain does not correctly include a Line component's values + * when stacked Bars are present — the domain is derived only from the bar data, so the + * reference line (e.g. Max IOPS) and any bars that exceed it get visually clipped. + * This function returns an explicit `[0, max]` domain when a visible reference line exists. + * + * @example + * // Max IOPS reference line at 25 000, bars reach up to 25 403 + * computeYAxisDomain({ maxAttributeKey: 'disk_iops_max', showMaxLine: true, ... }) + * // → [0, 25403] + * + * // Percentage chart zoomed in (no max line toggle) + * computeYAxisDomain({ isPercentage: true, showMaxValue: false, yMaxFromVisible: 75, ... }) + * // → [0, 75] + * + * // No max reference line — let Recharts auto-scale + * computeYAxisDomain({ maxAttributeKey: undefined, ... }) + * // → ['auto', 'auto'] + */ +export function computeYAxisDomain({ + isPercentage, + showMaxValue, + yMaxFromVisible, + maxAttributeKey, + showMaxLine, + data, + visibleAttributeNames, +}: { + isPercentage: boolean + showMaxValue: boolean + yMaxFromVisible: number + maxAttributeKey: string | undefined + showMaxLine: boolean + data: Record[] + visibleAttributeNames: string[] +}): [number, number] | ['auto', 'auto'] { + if (isPercentage && !showMaxValue) return [0, yMaxFromVisible] + if (!maxAttributeKey || !showMaxLine) return ['auto', 'auto'] + + const maxRefValue = data.reduce((max, point) => { + const val = point[maxAttributeKey] + return typeof val === 'number' ? Math.max(max, val) : max + }, 0) + + if (maxRefValue <= 0) return ['auto', 'auto'] + + const maxStackedTotal = data.reduce((max, point) => { + const total = visibleAttributeNames.reduce((sum, name) => { + const val = point[name] + return sum + (typeof val === 'number' ? val : 0) + }, 0) + return Math.max(max, total) + }, 0) + + return [0, Math.max(maxRefValue, maxStackedTotal)] +} + /** * Hook to create common wrapping components, perform data transformations * returns a Container component and the minHeight set diff --git a/apps/studio/components/ui/Charts/ComposedChart.tsx b/apps/studio/components/ui/Charts/ComposedChart.tsx index 10483b9705fb3..c2d9872dabe09 100644 --- a/apps/studio/components/ui/Charts/ComposedChart.tsx +++ b/apps/studio/components/ui/Charts/ComposedChart.tsx @@ -1,11 +1,12 @@ import dayjs from 'dayjs' import { formatBytes } from 'lib/helpers' import { useTheme } from 'next-themes' -import { ComponentProps, useEffect, useState } from 'react' +import { ComponentProps, useEffect, useMemo, useState } from 'react' import { Area, Bar, CartesianGrid, + Customized, Label, Line, ComposedChart as RechartComposedChart, @@ -14,11 +15,10 @@ import { Tooltip, XAxis, YAxis, - Customized, } from 'recharts' - import { CategoricalChartState } from 'recharts/types/chart/types' import { cn } from 'ui' + import { ChartHeader } from './ChartHeader' import { ChartHighlightAction, ChartHighlightActions } from './ChartHighlightActions' import { @@ -29,12 +29,12 @@ import { updateStackedChartColors, } from './Charts.constants' import { CommonChartProps, Datum } from './Charts.types' -import { formatPercentage, numberFormatter, useChartSize } from './Charts.utils' +import { computeYAxisDomain, formatPercentage, numberFormatter, useChartSize } from './Charts.utils' import { + calculateTotalChartAggregate, CustomLabel, CustomTooltip, MultiAttribute, - calculateTotalChartAggregate, } from './ComposedChart.utils' import NoDataPlaceholder from './NoDataPlaceholder' import { ChartHighlight } from './useChartHighlight' @@ -338,6 +338,28 @@ export function ComposedChart({ ) const yDomain = [0, yMaxFromVisible] + const yAxisDomain = useMemo( + () => + computeYAxisDomain({ + isPercentage, + showMaxValue, + yMaxFromVisible, + maxAttributeKey: maxAttribute?.attribute, + showMaxLine: _showMaxValue, + data, + visibleAttributeNames: visibleAttributes.map((a) => a.name), + }), + [ + isPercentage, + showMaxValue, + yMaxFromVisible, + maxAttribute, + _showMaxValue, + data, + visibleAttributes, + ] + ) + if (data.length === 0) { return ( compactNumberFormatter(value), }, defaultChartStyle: 'bar', From b0adce51c4457f71b75e3b393c779a41c5fdbef2 Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Wed, 25 Feb 2026 19:48:51 +0100 Subject: [PATCH 07/13] fix(proxy): avoid unconditional NextResponse.next() in Studio middleware (#43189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary - Fixes full page reloads on every client-side navigation in Studio caused by the first-referrer cookie middleware (#43153) - In the multi-zone production setup (www proxies /dashboard/* → Studio), returning `NextResponse.next()` unconditionally processes every response through Next.js's middleware pipeline, interfering with client-side navigation - Now only returns NextResponse.next() when a cookie actually needs to be stamped; returns undefined otherwise so Next.js handles the request untouched - Also fixes valid API routes (e.g. /api/ai/sql/generate-v4) falling through to cookie-stamping code instead of passing through cleanly --- apps/studio/proxy.ts | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/apps/studio/proxy.ts b/apps/studio/proxy.ts index e24258312d65a..ed872ad8d738b 100644 --- a/apps/studio/proxy.ts +++ b/apps/studio/proxy.ts @@ -1,8 +1,13 @@ -import { stampFirstReferrerCookie } from 'common/first-referrer-cookie' -import { IS_PLATFORM } from 'lib/constants' +import { + FIRST_REFERRER_COOKIE_NAME, + shouldRefreshCookie, + stampFirstReferrerCookie, +} from 'common/first-referrer-cookie' import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' +import { IS_PLATFORM } from '@/lib/constants' + // [Joshen] Return 404 for all next.js API endpoints EXCEPT the ones we use in hosted: const HOSTED_SUPPORTED_API_URLS = [ '/ai/sql/generate-v4', @@ -38,14 +43,31 @@ export function proxy(request: NextRequest) { { status: 404 } ) } + // Valid API route — pass through without middleware interference + return } // Belt & suspenders: stamp first-referrer cookie for direct Studio visits. // Primary stamping happens in www/docs middleware; this catches edge cases // like bookmarked Studio URLs with UTMs or direct-to-Studio paid traffic. - const response = NextResponse.next() - stampFirstReferrerCookie(request, response) - return response + // + // IMPORTANT: Only return NextResponse.next() when we actually need to set a + // cookie. In the multi-zone production setup (www proxies /dashboard/* → Studio), + // returning an explicit NextResponse.next() unconditionally causes the response + // to flow through Next.js's middleware response pipeline, which interferes with + // client-side navigation and triggers full page reloads. Returning undefined + // lets Next.js handle the request completely untouched. + const referrer = request.headers.get('referer') ?? '' + const { stamp } = shouldRefreshCookie(request.cookies.has(FIRST_REFERRER_COOKIE_NAME), { + referrer, + url: request.url, + }) + + if (stamp) { + const response = NextResponse.next() + stampFirstReferrerCookie(request, response) + return response + } } export const config = { From d95127982dc077a3d7248482bc9d1d988c33b902 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:58:16 -0500 Subject: [PATCH 08/13] chore: add sean romberg to humans.txt (#43181) --- apps/docs/public/humans.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index 4729acf5483ff..c8a6dbae32391 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -191,6 +191,7 @@ Sam Rose Sana Cordeaux Sara Read Sean Oliver +Sean Romberg Sean Thompson Sergio Cioban Filho Shane E From fc35630951c28636eed0ae0fb81b77263589920f Mon Sep 17 00:00:00 2001 From: Jeremias Menichelli Date: Wed, 25 Feb 2026 20:13:09 +0100 Subject: [PATCH 09/13] fix(Config): Change strong tag font weight to supported font spec (#43183) ## What kind of change does this PR introduce? Changes the base font weight for strong tags within the tailwind/typography configuration. ## What is the current behavior? I noticed that texts within our strong tags had an irregular stroke, and problems on rendering. The main problem is we are using the default 600 weight value coming from the config of tailwind, while our Circular custom font supports 400 and 600. _See the first text in the list from the screen just, like "Multi Protocol"._ Screenshot 2026-02-25 at 16 10 29 ## What is the new behavior? Strong tags now render with 500 font weight to prevent bad text rendering. Screenshot 2026-02-25 at 16 12 56 --- packages/config/tailwind.config.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/config/tailwind.config.js b/packages/config/tailwind.config.js index d30723dd1f077..6839541eb336a 100644 --- a/packages/config/tailwind.config.js +++ b/packages/config/tailwind.config.js @@ -183,6 +183,9 @@ const uiConfig = ui({ p: { fontWeight: '400', }, + strong: { + fontWeight: '500', + }, pre: { background: 'none', padding: 0, From 87ee98ed3d0f0fb92014984c4c2b1fdde7ebdee3 Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Wed, 25 Feb 2026 20:56:50 +0100 Subject: [PATCH 10/13] fix(proxy): remove first-referrer cookie stamping from Studio and Docs middleware (#43190) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary - Reverts the middleware changes to `apps/studio` and `apps/docs` from #43153 that caused full page reloads on every client-side navigation in Studio - Root cause: broadening the `middleware` matchers to match all routes and returning `NextResponse.next()` unconditionally interferes with client-side navigation in the multi-zone production setup (`www` proxies `/dashboard/*` → Studio, `/docs/*` → Docs) - Cookie stamping is unnecessary in Studio and Docs because `apps/www` sits in front of both apps in production and already handles first-referrer cookie attribution for all incoming traffic - The `apps/www` middleware, packages/common cookie utilities, and telemetry changes from #43153 are left intact Test plan - Verify client-side navigation works without full page reloads in Studio (production or preview deploy) - Verify first-referrer cookie is still stamped via `www` middleware on initial visit --- apps/docs/middleware.ts | 44 ++++++++----------------------- apps/studio/proxy.ts | 58 +++++++++-------------------------------- 2 files changed, 23 insertions(+), 79 deletions(-) diff --git a/apps/docs/middleware.ts b/apps/docs/middleware.ts index 02b6499dd9c3b..ea55aafa52efc 100644 --- a/apps/docs/middleware.ts +++ b/apps/docs/middleware.ts @@ -1,23 +1,17 @@ -import { clientSdkIds } from '~/content/navigation.references' -import { BASE_PATH } from '~/lib/constants' -import { stampFirstReferrerCookie } from 'common/first-referrer-cookie' import { isbot } from 'isbot' import { NextResponse, type NextRequest } from 'next/server' +import { clientSdkIds } from '~/content/navigation.references' +import { BASE_PATH } from '~/lib/constants' + const REFERENCE_PATH = `${BASE_PATH ?? ''}/reference` export function middleware(request: NextRequest) { const url = new URL(request.url) - - // Non-reference paths: just handle the first-referrer cookie and pass through if (!url.pathname.startsWith(REFERENCE_PATH)) { - const response = NextResponse.next() - stampFirstReferrerCookie(request, response) - return response + return NextResponse.next() } - // Reference paths: existing rewrite logic with cookie stamping on every response - if (isbot(request.headers.get('user-agent'))) { let [, lib, maybeVersion, ...slug] = url.pathname.replace(REFERENCE_PATH, '').split('/') @@ -30,9 +24,7 @@ export function middleware(request: NextRequest) { if (slug.length > 0) { const rewriteUrl = new URL(url) rewriteUrl.pathname = (BASE_PATH ?? '') + '/api/crawlers' - const response = NextResponse.rewrite(rewriteUrl) - stampFirstReferrerCookie(request, response) - return response + return NextResponse.rewrite(rewriteUrl) } } } @@ -41,42 +33,28 @@ export function middleware(request: NextRequest) { if (lib === 'cli') { const rewritePath = [REFERENCE_PATH, 'cli'].join('/') - const response = NextResponse.rewrite(new URL(rewritePath, request.url)) - stampFirstReferrerCookie(request, response) - return response + return NextResponse.rewrite(new URL(rewritePath, request.url)) } if (lib === 'api') { const rewritePath = [REFERENCE_PATH, 'api'].join('/') - const response = NextResponse.rewrite(new URL(rewritePath, request.url)) - stampFirstReferrerCookie(request, response) - return response + return NextResponse.rewrite(new URL(rewritePath, request.url)) } if (lib?.startsWith('self-hosting-')) { const rewritePath = [REFERENCE_PATH, lib].join('/') - const response = NextResponse.rewrite(new URL(rewritePath, request.url)) - stampFirstReferrerCookie(request, response) - return response + return NextResponse.rewrite(new URL(rewritePath, request.url)) } if (clientSdkIds.includes(lib)) { const version = /v\d+/.test(maybeVersion) ? maybeVersion : null const rewritePath = [REFERENCE_PATH, lib, version].filter(Boolean).join('/') - const response = NextResponse.rewrite(new URL(rewritePath, request.url)) - stampFirstReferrerCookie(request, response) - return response + return NextResponse.rewrite(new URL(rewritePath, request.url)) } - const response = NextResponse.next() - stampFirstReferrerCookie(request, response) - return response + return NextResponse.next() } export const config = { - matcher: [ - // Broadened from `/reference/:path*` to stamp first-referrer cookies on all - // docs pages, not just reference paths. Excludes Next.js internals and static files. - '/((?!api|_next/static|_next/image|_next/data|favicon.ico|__nextjs).*)', - ], + matcher: '/reference/:path*', } diff --git a/apps/studio/proxy.ts b/apps/studio/proxy.ts index ed872ad8d738b..27c941fb1c1c5 100644 --- a/apps/studio/proxy.ts +++ b/apps/studio/proxy.ts @@ -1,12 +1,9 @@ -import { - FIRST_REFERRER_COOKIE_NAME, - shouldRefreshCookie, - stampFirstReferrerCookie, -} from 'common/first-referrer-cookie' -import { NextResponse } from 'next/server' +import { IS_PLATFORM } from 'lib/constants' import type { NextRequest } from 'next/server' -import { IS_PLATFORM } from '@/lib/constants' +export const config = { + matcher: '/api/:function*', +} // [Joshen] Return 404 for all next.js API endpoints EXCEPT the ones we use in hosted: const HOSTED_SUPPORTED_API_URLS = [ @@ -32,44 +29,13 @@ const HOSTED_SUPPORTED_API_URLS = [ ] export function proxy(request: NextRequest) { - // API route filtering for hosted platform - if (request.nextUrl.pathname.startsWith('/api/')) { - if ( - IS_PLATFORM && - !HOSTED_SUPPORTED_API_URLS.some((url) => request.nextUrl.pathname.endsWith(url)) - ) { - return Response.json( - { success: false, message: 'Endpoint not supported on hosted' }, - { status: 404 } - ) - } - // Valid API route — pass through without middleware interference - return - } - - // Belt & suspenders: stamp first-referrer cookie for direct Studio visits. - // Primary stamping happens in www/docs middleware; this catches edge cases - // like bookmarked Studio URLs with UTMs or direct-to-Studio paid traffic. - // - // IMPORTANT: Only return NextResponse.next() when we actually need to set a - // cookie. In the multi-zone production setup (www proxies /dashboard/* → Studio), - // returning an explicit NextResponse.next() unconditionally causes the response - // to flow through Next.js's middleware response pipeline, which interferes with - // client-side navigation and triggers full page reloads. Returning undefined - // lets Next.js handle the request completely untouched. - const referrer = request.headers.get('referer') ?? '' - const { stamp } = shouldRefreshCookie(request.cookies.has(FIRST_REFERRER_COOKIE_NAME), { - referrer, - url: request.url, - }) - - if (stamp) { - const response = NextResponse.next() - stampFirstReferrerCookie(request, response) - return response + if ( + IS_PLATFORM && + !HOSTED_SUPPORTED_API_URLS.some((url) => request.nextUrl.pathname.endsWith(url)) + ) { + return Response.json( + { success: false, message: 'Endpoint not supported on hosted' }, + { status: 404 } + ) } } - -export const config = { - matcher: ['/((?!_next/static|_next/image|_next/data|favicon.ico|__nextjs).*)'], -} From ce980f1724bf7e6edc6e6485094115b01385a894 Mon Sep 17 00:00:00 2001 From: Prashant Sridharan <914007+CoolAssPuppy@users.noreply.github.com> Date: Wed, 25 Feb 2026 20:17:00 +0000 Subject: [PATCH 11/13] Modified past webinars and landing page to include YouTube embeds (#43191) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? I modified previous webinars to include the following: - A YouTube embed of the recording - New "Watch the Recording" CTA buttons - New slug for the go page `/vibe-coding-done-right-webinar` --------- Co-authored-by: Alan Daniel Co-authored-by: Claude Sonnet 4.6 --- .../replication/replication-setup.mdx | 1 + .../tutorials/with-solidjs.mdx | 42 +++++++++---------- ...5-09-10-migrating-from-firebase-mobbin.mdx | 8 ++-- .../2025-11-20-supabase-agency-webinar.mdx | 12 +++--- ...-02-25-enterprise-innovation-with-bolt.mdx | 20 ++++++--- .../_go/webinar/bolt-webinar-thank-you.tsx | 2 +- apps/www/_go/webinar/bolt-webinar.tsx | 8 ++-- apps/www/pages/aup.mdx | 2 +- 8 files changed, 51 insertions(+), 44 deletions(-) diff --git a/apps/docs/content/guides/database/replication/replication-setup.mdx b/apps/docs/content/guides/database/replication/replication-setup.mdx index e334a4ab02fb3..f0f3ce70a7eb6 100644 --- a/apps/docs/content/guides/database/replication/replication-setup.mdx +++ b/apps/docs/content/guides/database/replication/replication-setup.mdx @@ -149,6 +149,7 @@ First, create an analytics bucket to store your replicated data: 3. Fill in the bucket details: {' '} + Analytics bucket details <$CodeSample - path="/user-management/solid-user-management/.env.example" - lines={[[1, -1]]} - meta="name=.env" +path="/user-management/solid-user-management/.env.example" +lines={[[1, -1]]} +meta="name=.env" /> @@ -52,9 +52,9 @@ on the browser, and that's completely fine since you have [Row Level Security](/ <$CodeTabs> <$CodeSample - path="/user-management/solid-user-management/src/supabaseClient.tsx" - lines={[[1, -1]]} - meta="name=src/supabaseClient.tsx" +path="/user-management/solid-user-management/src/supabaseClient.tsx" +lines={[[1, -1]]} +meta="name=src/supabaseClient.tsx" /> @@ -71,9 +71,9 @@ Set up a SolidJS component to manage logins and sign ups using Magic Links, so u <$CodeTabs> <$CodeSample - path="/user-management/solid-user-management/src/Auth.tsx" - lines={[[1, -1]]} - meta="name=src/Auth.tsx" +path="/user-management/solid-user-management/src/Auth.tsx" +lines={[[1, -1]]} +meta="name=src/Auth.tsx" /> @@ -87,9 +87,9 @@ Create a new component for that called `Account.tsx`. <$CodeTabs> <$CodeSample - path="/user-management/solid-user-management/src/Account.tsx" - lines={[[1, 1], [3, 78], [87, -1]]} - meta="name=src/Account.tsx" +path="/user-management/solid-user-management/src/Account.tsx" +lines={[[1, 1], [3, 78], [87, -1]]} +meta="name=src/Account.tsx" /> @@ -101,9 +101,9 @@ Now that you have all the components in place, update `App.tsx`: <$CodeTabs> <$CodeSample - path="/user-management/solid-user-management/src/App.tsx" - lines={[[1, -1]]} - meta="name=src/App.tsx" +path="/user-management/solid-user-management/src/App.tsx" +lines={[[1, -1]]} +meta="name=src/App.tsx" /> @@ -129,9 +129,9 @@ Create an avatar for the user so that they can upload a profile photo. Start by <$CodeTabs> <$CodeSample - path="/user-management/solid-user-management/src/Avatar.tsx" - lines={[[1, -1]]} - meta="name=src/Avatar.tsx" +path="/user-management/solid-user-management/src/Avatar.tsx" +lines={[[1, -1]]} +meta="name=src/Avatar.tsx" /> @@ -143,9 +143,9 @@ And then add the widget to the Account page: <$CodeTabs> <$CodeSample - path="/user-management/solid-user-management/src/Account.tsx" - lines={[[1, 3], [76, 88]]} - meta="name=src/Account.tsx" +path="/user-management/solid-user-management/src/Account.tsx" +lines={[[1, 3], [76, 88]]} +meta="name=src/Account.tsx" /> diff --git a/apps/www/_events/2025-09-10-migrating-from-firebase-mobbin.mdx b/apps/www/_events/2025-09-10-migrating-from-firebase-mobbin.mdx index 79a789db6ac76..462622a418f25 100644 --- a/apps/www/_events/2025-09-10-migrating-from-firebase-mobbin.mdx +++ b/apps/www/_events/2025-09-10-migrating-from-firebase-mobbin.mdx @@ -18,9 +18,9 @@ company: categories: - webinar main_cta: - url: 'https://zoom.us/webinar/register/WN_cMSVDLRSRKS5cH8EmmUUQg' - target: _blank - label: Register now + url: '#recording' + target: _self + label: Watch the recording speakers: 'dventimi,jian_jie' hosts: - name: Supabase @@ -38,7 +38,7 @@ In this 45-minute session, Supabase engineers and Mobbin CEO Liau Jian Jie break This session is built for startup founders and technical leads who want to stay fast without giving up control. -
+