diff --git a/.claude/skills/telemetry-standards/SKILL.md b/.claude/skills/telemetry-standards/SKILL.md new file mode 100644 index 0000000000000..0ed2f7f85bc03 --- /dev/null +++ b/.claude/skills/telemetry-standards/SKILL.md @@ -0,0 +1,170 @@ +--- +name: telemetry-standards +description: PostHog event tracking standards for Supabase Studio. Use when reviewing + PRs for telemetry compliance or implementing new event tracking. Covers event naming, + property conventions, approved patterns, and implementation guide. +--- + +# Telemetry Standards for Supabase Studio + +Standards for PostHog event tracking in `apps/studio/`. Apply these when +reviewing PRs that touch tracking or when implementing new tracking. + +## Event Naming + +**Format:** `[object]_[verb]` in snake_case + +**Approved verbs only:** +opened, clicked, submitted, created, removed, updated, retrieved, intended, evaluated, added, +enabled, disabled, copied, exposed, failed, converted + +**Flag these:** +- Unapproved verbs (saved, viewed, seen, pressed, etc.) +- Wrong order: `click_product_card` → should be `product_card_clicked` +- Wrong casing: `productCardClicked` → should be `product_card_clicked` + +**Good examples:** +- `product_card_clicked` +- `backup_button_clicked` +- `sql_query_submitted` + +**Common mistakes with corrections:** +- `database_saved` → `save_button_clicked` or `database_updated` (unapproved verb) +- `click_backup_button` → `backup_button_clicked` (wrong order) +- `dashboardViewed` → don't track passive views on page load +- `component_rendered` → don't track — no user interaction + +## Property Standards + +**Casing:** camelCase preferred for new events. The codebase has existing snake_case properties (e.g., `schema_name`, `table_name`) — when adding properties to an existing event, match its established convention. + +**Names must be self-explanatory:** +- `{ productType: 'database', planTier: 'pro' }` +- `{ assistantType: 'sql', suggestionType: 'optimization' }` + +**Flag these:** +- Generic names: `label`, `value`, `name`, `data` +- PascalCase properties +- Inconsistent names across similar events (e.g., `assistantType` in one event, `aiType` in a related event) +- Mixing camelCase and snake_case within the same event + +## What NOT to Track + +- Passive views/renders on page load (`dashboard_viewed`, `sidebar_appeared`, `page_loaded`) +- Component appearances without user interaction +- Generic "viewed" or "seen" events — already captured by pageview events + +**DO track:** user clicks, form submissions, explicit opens/closes, user-initiated actions. + +**Exception:** `_exposed` events for A/B experiment exposure tracking are valid even though they fire on render. + +**Never track PII** (emails, names, IPs, etc.) in event properties. + +## Required Pattern + +Import `useTrack` from `lib/telemetry/track` (within `apps/studio/`). Never use `useSendEventMutation` (deprecated). + +```typescript +import { useTrack } from 'lib/telemetry/track' + +const MyComponent = () => { + const track = useTrack() + + const handleClick = () => { + track('product_card_clicked', { + productType: 'database', + planTier: 'pro', + source: 'dashboard', + }) + } + + return +} +``` + +## Event Definitions + +All events must be defined as TypeScript interfaces in `packages/common/telemetry-constants.ts`: + +```typescript +/** + * [Event description] + * + * @group Events + * @source [what triggers this event] + */ +export interface MyFeatureClickedEvent { + action: 'my_feature_clicked' + properties: { + /** Description of property */ + featureType: string + } + groups: TelemetryGroups +} +``` + +Add the new interface to the `TelemetryEvent` union type so `useTrack` picks it up. +`@group Events` and `@source` must be accurate. + +## Review Rules + +When reviewing a PR, flag these as **required changes:** + +1. **Naming violations** — event not following `[object]_[verb]` snake_case, or using an unapproved verb +2. **Property violations** — not camelCase, generic names, or inconsistent with similar events +3. **Deprecated hook** — any usage of `useSendEventMutation` instead of `useTrack` +4. **Unnecessary view tracking** — events that fire on page load without user interaction +5. **Inaccurate docs** — `@page`/`@source` descriptions that don't match the actual implementation + +When a PR adds user-facing interactions (buttons, forms, toggles, modals) **without** tracking, suggest: +- "This adds a user interaction that may benefit from tracking." +- Propose the event name following `[object]_[verb]` convention +- Propose the `useTrack()` call with suggested properties + +When checking property consistency, search `packages/common/telemetry-constants.ts` for similar events and verify property names match. + +## Well-Formed Event Examples + +From the actual codebase: + +```typescript +// User copies a connection string +track('connection_string_copied', { + connectionType: 'psql', + connectionMethod: 'transaction_pooler', + connectionTab: 'Connection String', +}) + +// User enables a feature preview +track('feature_preview_enabled', { + feature: 'realtime_inspector', +}) + +// User clicks a banner CTA +track('index_advisor_banner_dismiss_button_clicked') + +// Experiment exposure (fires on render — valid exception) +track('home_new_experiment_exposed', { + variant: 'treatment', +}) +``` + +## Implementing New Tracking + +To add tracking for a user action: + +1. **Name the event** — `[object]_[verb]` using approved verbs only +2. **Choose properties** — camelCase preferred for new events; check `packages/common/telemetry-constants.ts` for similar events and match their property names and casing +3. **Add interface to telemetry-constants.ts** — with `@group Events` and `@source` JSDoc, add to the `TelemetryEvent` union type +4. **Add to component** — `import { useTrack } from 'lib/telemetry/track'`, call `track('event_name', { properties })` + +### Verification checklist + +- [ ] Event name follows `[object]_[verb]` with approved verb +- [ ] Event name is snake_case +- [ ] Properties are camelCase and self-explanatory +- [ ] Event defined in telemetry-constants.ts with accurate `@page`/`@source` +- [ ] Using `useTrack` hook (not `useSendEventMutation`) +- [ ] Not tracking passive views/appearances +- [ ] No PII in event properties (emails, names, IPs, etc.) +- [ ] Property names consistent with similar events diff --git a/apps/docs/content/troubleshooting/all-about-supabase-egress-a_Sg_e.mdx b/apps/docs/content/troubleshooting/all-about-supabase-egress-a_Sg_e.mdx index ecbb16f8beaf5..203e64a42dd1e 100644 --- a/apps/docs/content/troubleshooting/all-about-supabase-egress-a_Sg_e.mdx +++ b/apps/docs/content/troubleshooting/all-about-supabase-egress-a_Sg_e.mdx @@ -9,7 +9,7 @@ database_id = "ba593989-12b5-464f-8ede-a525e1ca2ffb" **What is Egress?** -Egress (also known as bandwidth) is any amount of network packets/bytes being streamed back to a connected client. Means, the data that is leaving the Supabase platform. Egress in Supabase includes any calls through PostgREST, to Storage, Realtime, Auth, Edge Functions, Database and Supavisor. +Egress (also known as bandwidth) is any amount of network packets/bytes being streamed to a connected client from your project. Means, the data that is leaving the Supabase platform. Egress in Supabase includes any calls through PostgREST, to Storage, Realtime, Auth, Edge Functions, Database and Supavisor. You can read about Unified egress, included quota, and how to check the egress usage here: https://supabase.com/docs/guides/platform/manage-your-usage/egress. Additionally, the [project reports](/dashboard/project/_/observability) have a few egress related stats. You can create a custom report to look into daily egress. diff --git a/apps/docs/public/img/troubleshooting/reports-page-egress.png b/apps/docs/public/img/troubleshooting/reports-page-egress.png new file mode 100644 index 0000000000000..8801fc6fd8e3b Binary files /dev/null and b/apps/docs/public/img/troubleshooting/reports-page-egress.png differ diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx index 332e9c7cc8d67..f7c23432868de 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx @@ -1,9 +1,4 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { ExternalLink } from 'lucide-react' -import { useTheme } from 'next-themes' -import Image from 'next/image' -import Link from 'next/link' - import { useFlag, useParams } from 'common' import { ScaffoldSection, @@ -19,10 +14,15 @@ import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { BASE_PATH, DOCS_URL } from 'lib/constants' import { MANAGED_BY } from 'lib/constants/infrastructure' +import { ExternalLink } from 'lucide-react' +import { useTheme } from 'next-themes' +import Image from 'next/image' +import Link from 'next/link' import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings' -import { Alert, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui' +import { Alert, Alert_Shadcn_, AlertTitle_Shadcn_, Button } from 'ui' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' -import ProjectUpdateDisabledTooltip from '../ProjectUpdateDisabledTooltip' + +import { ProjectUpdateDisabledTooltip } from '../ProjectUpdateDisabledTooltip' import SpendCapSidePanel from './SpendCapSidePanel' export interface CostControlProps {} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip.tsx index 4956a78098afb..a02b33007307d 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip.tsx @@ -7,29 +7,28 @@ export interface ProjectUpdateDisabledTooltipProps { tooltip?: string } -const ProjectUpdateDisabledTooltip = ({ +export const ProjectUpdateDisabledTooltip = ({ projectUpdateDisabled, projectNotActive = false, children, tooltip, }: PropsWithChildren) => { - const showTooltip = projectUpdateDisabled || projectNotActive + const tooltipMessage = + tooltip || + (projectUpdateDisabled + ? 'Subscription changes are currently disabled. Our engineers are working on a fix.' + : projectNotActive + ? 'Unable to update subscription as project is currently not active' + : undefined) return ( {children} - {showTooltip && ( - - {projectUpdateDisabled - ? tooltip || - 'Subscription changes are currently disabled. Our engineers are working on a fix.' - : projectNotActive - ? 'Unable to update subscription as project is currently not active' - : ''} + {tooltipMessage !== undefined && ( + + {tooltipMessage} )} ) } - -export default ProjectUpdateDisabledTooltip diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/DowngradeModal.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/DowngradeModal.tsx index 198c988faa2aa..a9decb09b3dca 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/DowngradeModal.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/DowngradeModal.tsx @@ -1,7 +1,6 @@ -import { MinusCircle, PauseCircle } from 'lucide-react' - import { getComputeSize, OrgProject } from 'data/projects/org-projects-infinite-query' import type { OrgSubscription, ProjectAddon } from 'data/subscriptions/types' +import { MinusCircle, PauseCircle } from 'lucide-react' import { useMemo } from 'react' import { plans as subscriptionsPlans } from 'shared-data/plans' import { Modal } from 'ui' diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx index 2ab3378a058a9..fec46b51718bd 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx @@ -1,13 +1,13 @@ -import { useState } from 'react' -import { toast } from 'sonner' - import { useFlag, useParams } from 'common' import { CANCELLATION_REASONS } from 'components/interfaces/Billing/Billing.constants' import { useSendDowngradeFeedbackMutation } from 'data/feedback/exit-survey-send' import { getComputeSize, OrgProject } from 'data/projects/org-projects-infinite-query' import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscription-update-mutation' +import { useState } from 'react' +import { toast } from 'sonner' import { Alert, Button, cn, Input, Modal } from 'ui' -import ProjectUpdateDisabledTooltip from '../ProjectUpdateDisabledTooltip' + +import { ProjectUpdateDisabledTooltip } from '../ProjectUpdateDisabledTooltip' export interface ExitSurveyModalProps { visible: boolean @@ -104,7 +104,7 @@ export const ExitSurveyModal = ({ visible, projects, onClose }: ExitSurveyModalP

- Share with us why you're downgrading your plan. + What made you decide to downgrade your plan?

diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx index 8494343da253f..f3f6b37074cfb 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx @@ -1,6 +1,4 @@ import { PermissionAction, SupportCategories } from '@supabase/shared-types/out/constants' -import Link from 'next/link' - import { useFlag, useParams } from 'common' import { SupportLink } from 'components/interfaces/Support/SupportLink' import { @@ -12,11 +10,13 @@ import AlertError from 'components/ui/AlertError' import NoPermission from 'components/ui/NoPermission' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' +import Link from 'next/link' import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings' import { Alert, Button } from 'ui' import { Admonition } from 'ui-patterns' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' -import ProjectUpdateDisabledTooltip from '../ProjectUpdateDisabledTooltip' + +import { ProjectUpdateDisabledTooltip } from '../ProjectUpdateDisabledTooltip' import { Restriction } from '../Restriction' import { PlanUpdateSidePanel } from './PlanUpdateSidePanel' diff --git a/apps/studio/components/interfaces/Settings/Addons/Addons.tsx b/apps/studio/components/interfaces/Settings/Addons/Addons.tsx index 66bcbd2b07af8..f672118ea3abf 100644 --- a/apps/studio/components/interfaces/Settings/Addons/Addons.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/Addons.tsx @@ -5,7 +5,7 @@ import { subscriptionHasHipaaAddon, } from 'components/interfaces/Billing/Subscription/Subscription.utils' import { NoticeBar } from 'components/interfaces/DiskManagement/ui/NoticeBar' -import ProjectUpdateDisabledTooltip from 'components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip' +import { ProjectUpdateDisabledTooltip } from 'components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip' import { SupportLink } from 'components/interfaces/Support/SupportLink' import { ScaffoldContainer, @@ -27,6 +27,7 @@ import dayjs from 'dayjs' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { + useIsAwsCloudProvider, useIsOrioleDbInAws, useIsProjectActive, useSelectedProjectQuery, @@ -52,6 +53,7 @@ export const Addons = () => { const { resolvedTheme } = useTheme() const { ref: projectRef } = useParams() const { setPanel } = useAddonsPagePanel() + const isAws = useIsAwsCloudProvider() const isProjectActive = useIsProjectActive() const isOrioleDbInAws = useIsOrioleDbInAws() @@ -184,7 +186,7 @@ export const Addons = () => { )} - +

Compute Size

@@ -358,7 +360,7 @@ export const Addons = () => { <> - +

Dedicated IPv4 address

@@ -409,13 +411,21 @@ export const Addons = () => { +
} > diff --git a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx index 872f88ea00833..a381a9c9326b0 100644 --- a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx @@ -1,10 +1,6 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { ExternalLink } from 'lucide-react' -import Link from 'next/link' -import { useEffect, useState } from 'react' -import { toast } from 'sonner' - import { useParams } from 'common' +import { DocsButton } from 'components/ui/DocsButton' import { InlineLink } from 'components/ui/InlineLink' import { useProjectAddonRemoveMutation } from 'data/subscriptions/project-addon-remove-mutation' import { useProjectAddonUpdateMutation } from 'data/subscriptions/project-addon-update-mutation' @@ -16,8 +12,11 @@ import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization import { useIsAwsCloudProvider } from 'hooks/misc/useSelectedProject' import { DOCS_URL } from 'lib/constants' import { formatCurrency } from 'lib/helpers' +import Link from 'next/link' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' import { useAddonsPagePanel } from 'state/addons-page' -import { Button, Radio, SidePanel, cn } from 'ui' +import { Button, cn, RadioGroup_Shadcn_, RadioGroupLargeItem_Shadcn_, SidePanel } from 'ui' import { Admonition } from 'ui-patterns' const IPv4SidePanel = () => { @@ -62,12 +61,41 @@ const IPv4SidePanel = () => { const availableOptions = (addons?.available_addons ?? []).find((addon) => addon.type === 'ipv4')?.variants ?? [] - const isFreePlan = organization?.plan?.id === 'free' const { hasAccess: hasAccessToIPv4, isLoading: isLoadingEntitlement } = useCheckEntitlements('ipv4') const hasChanges = selectedOption !== (subscriptionIpV4Option?.variant.identifier ?? 'ipv4_none') const selectedIPv4 = availableOptions.find((option) => option.identifier === selectedOption) - const isPgBouncerEnabled = !isFreePlan + + const ipv4Options = [ + { + value: 'ipv4_none', + id: 'ipv4_none', + title: 'No IPv4 address', + description: 'Use shared pooler or IPv6 for database connections.', + priceContent: ( + <> +

$0

+

/ month

+ + ), + priceRowClassName: 'mt-2', + }, + ...availableOptions.map((option) => ({ + value: option.identifier, + id: option.identifier, + title: 'Dedicated IPv4 address', + description: 'Allow database connections from IPv4 networks.', + priceContent: ( + <> +

+ {formatCurrency(option.price)} +

+

/ month / database

+ + ), + priceRowClassName: 'mt-3', + })), + ] useEffect(() => { if (visible) { @@ -112,26 +140,22 @@ const IPv4SidePanel = () => { : undefined } header={ -
+

Dedicated IPv4 address

- +
} >

- Direct connections to the database only work if your client is able to resolve IPv6 - addresses. Enabling the dedicated IPv4 add-on allows you to directly connect to your - database via a IPv4 address. + Your project’s direct connection endpoint and dedicated pooler are IPv6-only by default. + Enable the dedicated IPv4 address add-on to connect from IPv4-only networks. +

+ +

+ The shared pooler endpoint accepts IPv4 connections by default and does not require this + add-on.

{!isAws && ( @@ -141,84 +165,42 @@ const IPv4SidePanel = () => { /> )} - {isPgBouncerEnabled ? ( - - ) : ( -

- If you are connecting via the Shared connection pooler, you do not need this add-on as - our pooler resolves to IPv4 addresses. You can check your connection info in your{' '} - - project database settings - - . -

- )} - -
- setSelectedOption(event.target.value)} - > - + -
-
-

No IPv4 address

-
-
-

- Use connection pooler or IPv6 for direct connections -

-
-

$0

-

/ month

-
-
-
-
- {availableOptions.map((option) => ( - -
-
-

Dedicated IPv4 address

-
-
-

- Allow direct database connections via IPv4 address -

-
-

- {formatCurrency(option.price)} -

-

- / month / database -

+ {ipv4Options.map((option) => ( + +
+

{option.title}

+

{option.description}

+
+ {option.priceContent}
-
- - ))} - -
+ + ))} + +
+ )} {hasChanges && ( <> @@ -230,29 +212,25 @@ const IPv4SidePanel = () => { /> {selectedOption !== 'ipv4_none' && (

- By default, this is only applied to the Primary database for your project. If{' '} - - Read replicas - {' '} + By default, this is only applied to the primary database for your project. If{' '} + + read replicas + {' '} are used, each replica also gets its own IPv4 address, with a corresponding{' '} {formatCurrency(selectedIPv4?.price)}{' '} charge.

)}

- There are no immediate charges. The addon is billed at the end of your billing cycle - based on your usage and prorated to the hour. + There are no immediate charges. The add-on is billed at the end of your billing + cycle based on your usage and prorated to the hour.

)} {!hasAccessToIPv4 && ( -

Upgrade your plan to enable a IPv4 address for your project

+

Upgrade your plan to enable an IPv4 address for your project

+
} > diff --git a/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx index 08419a505c381..b39caa3c99e6b 100644 --- a/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx +++ b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx @@ -8,6 +8,7 @@ import z from 'zod' import { useParams } from 'common' import AlertError from 'components/ui/AlertError' +import { Button } from 'ui' import { DocsButton } from 'components/ui/DocsButton' import { setValueAsNullableNumber } from 'components/ui/Forms/Form.constants' import { FormActions } from 'components/ui/Forms/FormActions' @@ -46,6 +47,7 @@ import { Admonition } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { POOLING_OPTIMIZATIONS } from './ConnectionPooling.constants' +import Link from 'next/link' const formId = 'pooling-configuration-form' @@ -161,15 +163,19 @@ export const ConnectionPooling = () => { {isSuccessAddons && !disablePoolModeSelection && !hasIpv4Addon && ( - -

- If your network only supports IPv4, consider purchasing the{' '} - - IPv4 add-on - - . -

-
+ + + Enable IPv4 add-on + + + } + /> )}
-

- Help us improve by sharing why you're deleting your project. -

+

What made you decide to delete your project?

diff --git a/apps/studio/data/api-keys/temp-api-keys-utils.test.ts b/apps/studio/data/api-keys/temp-api-keys-utils.test.ts index 3339d0755d4e4..0782f4438fd31 100644 --- a/apps/studio/data/api-keys/temp-api-keys-utils.test.ts +++ b/apps/studio/data/api-keys/temp-api-keys-utils.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + import { createTemporaryApiKey, isTemporaryApiKeyValid, @@ -86,7 +87,7 @@ describe('isTemporaryUploadKeyValid', () => { expect(result).toBe(false) }) - it('should return true for a key with more than 20 seconds remaining', () => { + it('should return true for a key with more than 30 seconds remaining', () => { const now = Date.now() vi.setSystemTime(now) @@ -100,13 +101,13 @@ describe('isTemporaryUploadKeyValid', () => { expect(result).toBe(true) }) - it('should return false for a key with exactly 20 seconds remaining', () => { + it('should return false for a key with exactly 30 seconds remaining', () => { const now = Date.now() vi.setSystemTime(now) const key: TemporaryApiKey = { apiKey: 'test-key', - expiryTimeMs: now + 20000, // Exactly 20 seconds + expiryTimeMs: now + 30000, // Exactly 30 seconds } const result = isTemporaryApiKeyValid(key) @@ -114,7 +115,7 @@ describe('isTemporaryUploadKeyValid', () => { expect(result).toBe(false) }) - it('should return false for a key with less than 20 seconds remaining', () => { + it('should return false for a key with less than 30 seconds remaining', () => { const now = Date.now() vi.setSystemTime(now) @@ -156,13 +157,13 @@ describe('isTemporaryUploadKeyValid', () => { expect(result).toBe(false) }) - it('should return true for a key with exactly 21 seconds remaining', () => { + it('should return true for a key with exactly 31 seconds remaining', () => { const now = Date.now() vi.setSystemTime(now) const key: TemporaryApiKey = { apiKey: 'test-key', - expiryTimeMs: now + 21000, // 21 seconds from now + expiryTimeMs: now + 31000, // 31 seconds from now } const result = isTemporaryApiKeyValid(key) @@ -182,11 +183,11 @@ describe('isTemporaryUploadKeyValid', () => { // Initially valid expect(isTemporaryApiKeyValid(key)).toBe(true) - // Advance time by 99 seconds (should still be valid - 21 seconds remaining) - vi.advanceTimersByTime(99000) + // Advance time by 89 seconds (should still be valid - 31 seconds remaining) + vi.advanceTimersByTime(89000) expect(isTemporaryApiKeyValid(key)).toBe(true) - // Advance time by 2 more seconds (should be invalid - 19 seconds remaining) + // Advance time by 2 more seconds (should be invalid - 29 seconds remaining) vi.advanceTimersByTime(2000) expect(isTemporaryApiKeyValid(key)).toBe(false) }) @@ -238,7 +239,7 @@ describe('integration: createTemporaryUploadKey and isTemporaryUploadKeyValid', expect(isTemporaryApiKeyValid(key)).toBe(true) }) - it('should create a key that becomes invalid after expiry time minus 20 seconds', () => { + it('should create a key that becomes invalid after expiry time minus 30 seconds', () => { const now = Date.now() vi.setSystemTime(now) @@ -248,11 +249,11 @@ describe('integration: createTemporaryUploadKey and isTemporaryUploadKeyValid', // Initially valid expect(isTemporaryApiKeyValid(key)).toBe(true) - // Advance to 19 seconds before expiry (should still be valid - 21 seconds remaining) - vi.advanceTimersByTime((expiryInSeconds - 21) * 1000) + // Advance to 29 seconds before expiry (should still be valid - 31 seconds remaining) + vi.advanceTimersByTime((expiryInSeconds - 31) * 1000) expect(isTemporaryApiKeyValid(key)).toBe(true) - // Advance to 20 seconds before expiry (should be invalid - 20 seconds remaining) + // Advance to 20 seconds before expiry (should be invalid - 29 seconds remaining) vi.advanceTimersByTime(1000) expect(isTemporaryApiKeyValid(key)).toBe(false) }) @@ -261,38 +262,38 @@ describe('integration: createTemporaryUploadKey and isTemporaryUploadKeyValid', const now = Date.now() vi.setSystemTime(now) - // Create a key that expires in 10 seconds (less than the 20 second buffer) + // Create a key that expires in 10 seconds (less than the 30 second buffer) const key = createTemporaryApiKey('test-api-key', 10) - // Should be invalid immediately because it will expire in less than 20 seconds + // Should be invalid immediately because it will expire in less than 30 seconds expect(isTemporaryApiKeyValid(key)).toBe(false) }) - it('should handle expiry duration of exactly 20 seconds', () => { + it('should handle expiry duration of exactly 30 seconds', () => { const now = Date.now() vi.setSystemTime(now) - // Create a key that expires in exactly 20 seconds - const key = createTemporaryApiKey('test-api-key', 20) + // Create a key that expires in exactly 30 seconds + const key = createTemporaryApiKey('test-api-key', 30) - // Should be invalid because it has exactly 20 seconds remaining (not more than 20) + // Should be invalid because it has exactly 30 seconds remaining (not more than 30) expect(isTemporaryApiKeyValid(key)).toBe(false) }) - it('should handle expiry duration of 21 seconds', () => { + it('should handle expiry duration of 31 seconds', () => { const now = Date.now() vi.setSystemTime(now) - // Create a key that expires in 21 seconds - const key = createTemporaryApiKey('test-api-key', 21) + // Create a key that expires in 31 seconds + const key = createTemporaryApiKey('test-api-key', 31) - // Should be valid because it has 21 seconds remaining (more than 20) + // Should be valid because it has 31 seconds remaining (more than 30) expect(isTemporaryApiKeyValid(key)).toBe(true) // Advance by 1 second vi.advanceTimersByTime(1000) - // Should now be invalid because it has exactly 20 seconds remaining + // Should now be invalid because it has exactly 30 seconds remaining expect(isTemporaryApiKeyValid(key)).toBe(false) }) }) diff --git a/apps/studio/data/api-keys/temp-api-keys-utils.ts b/apps/studio/data/api-keys/temp-api-keys-utils.ts index 9b87938ab2408..3607479952d59 100644 --- a/apps/studio/data/api-keys/temp-api-keys-utils.ts +++ b/apps/studio/data/api-keys/temp-api-keys-utils.ts @@ -23,7 +23,9 @@ export function isTemporaryApiKeyValid( const now = Date.now() const timeRemaining = key.expiryTimeMs - now - return timeRemaining > 20_000 // More than 20 seconds remaining + // Consider the key invalid if it has less than 30 seconds remaining to avoid edge cases where the key + // expires during use. + return timeRemaining > 30_000 } const checkOrRefreshTemporaryApiKey = async ( diff --git a/apps/www/_customers/brevo.mdx b/apps/www/_customers/brevo.mdx index db64499c08a18..71334f2e36270 100644 --- a/apps/www/_customers/brevo.mdx +++ b/apps/www/_customers/brevo.mdx @@ -28,7 +28,10 @@ region: 'Europe' supabase_products: ['database'] --- - + Our AI agents are only as good as the data they can reach. Supabase gave them access to our entire CRM, stored what they produced, and never got in the way. It is the quiet part of our stack that makes everything else work. @@ -44,10 +47,13 @@ All of that intelligence sits in the CRM. Contacts, Deals, Companies. Years of a Alexandre Le Goupil runs Revenue Systems and AI for Brevo's sales team. His group had adopted **[Dust](https://dust.tt), an AI agent platform**, and the agents were already good at research, reasoning, and writing. But the CRM was completely out of reach, and that data was what would make the agents' output truly actionable. - - Salespeople would come to me and ask for a list of our best e-commerce customers before a call. Or they - would need to know if we had history with a prospect's company. That context was all in the CRM, - but our AI agents were completely blind to it. I needed to connect those two worlds. + + Salespeople would come to me and ask for a list of our best e-commerce customers before a call. Or + they would need to know if we had history with a prospect's company. That context was all in the + CRM, but our AI agents were completely blind to it. I needed to connect those two worlds. Other teams at Brevo ran their analytics on BigQuery, Snowflake, and Databricks. None of those were built for what Alexandre needed: a database that AI agents could read from and write to in real time, through a standard protocol, without a custom integration for every workflow. @@ -56,7 +62,10 @@ Other teams at Brevo ran their analytics on BigQuery, Snowflake, and Databricks. Alexandre found his answer in the [Supabase MCP server](/docs/guides/getting-started/mcp). MCP (Model Context Protocol) gives AI agents a structured way to interact with databases. Supabase ships one out of the box. That meant a direct connection between Dust's AI agents and a Postgres database, with read and write permissions controlled at the tool level. - + Supabase shipping a remote MCP server means any Dust agent can connect to it instantly, with read and write access, no custom integration needed. That is exactly the model we want to see from data platforms: natively agent-ready, so teams can focus on building workflows instead of plumbing. @@ -64,20 +73,26 @@ Alexandre found his answer in the [Supabase MCP server](/docs/guides/getting-sta According to Dust's engineering team, Supabase was the first data platform where they enabled both read and write capabilities through natural language. Snowflake has since followed with its own MCP, and Databricks is next. But Supabase got there first, and for teams like Alexandre's that need to iterate fast with AI, that head start mattered. - - BigQuery and Snowflake are great for analytics. But I needed something an AI agent could query live, - in the middle of a conversation. Supabase gave me Postgres with an MCP server ready to go. That was - what decided it. + + BigQuery and Snowflake are great for analytics. But I needed something an AI agent could query + live, in the middle of a conversation. Supabase gave me Postgres with an MCP server ready to go. + That was what decided it. Brevo actually started with a custom MCP connection before Dust launched its official Supabase integration in June 2025. When the official version shipped, they migrated. The fact that they went from a scrappy custom setup to the supported integration says something about commitment: this was not an experiment. The team is Revenue Operations, not database engineering. Speed mattered. Alexandre connected Supabase to Dust in days. He defined his tables, wrote descriptions of every field so the LLM would know what each one meant, and started testing. - + Connecting Supabase was the easy part. The real work was writing good documentation for the AI, - telling it what each field means, which tables to query for which questions. Once we got that right, - everything clicked. + telling it what each field means, which tables to query for which questions. Once we got that + right, everything clicked. The Supabase MCP was well-documented and stable, and the integration was live on the platform level within minutes. Alexandre's investment in that documentation is what separates a good Dust agent from a great one. @@ -90,7 +105,10 @@ Brevo's sales reps now ask a Dust agent: "Find me the top three e-commerce custo Before, that request went to RevOps. Someone would pull a report, filter it, and send it back. Fifteen minutes per request, minimum. - + That workflow alone changed how reps prepare for calls. They get reference customers, account context, and a suggested angle in seconds. It freed up hours of my team's time every week. @@ -103,7 +121,10 @@ The agent also grabs context from the web. LinkedIn profiles, firmographic data, The emails come back as structured JSON and HTML. Dust writes them directly to Supabase. Brevo's CRM then pulls those emails into multi-channel sales sequences: email, phone, LinkedIn. - + We used to send the same generic email to every e-commerce prospect. Now every email reflects who this person is and what we know about them. Supabase holds the context going in and stores the output coming out. It is the connective tissue between our CRM and our AI. @@ -117,7 +138,10 @@ The team also built anti-hallucination guardrails into their prompts. A sales em Brevo's marketing team uses the same architecture for lead generation. A visitor enters their email and company name on a [landing page](https://www.brevo.com/tools/relationship-plan?utm_medium=web-external&utm_source=CTA_dust_blog&utm_campaign=en_supabase_blog_202602). The data goes into Supabase and triggers a Dust agent that generates a complete marketing plan for that company: channel recommendations, campaign ideas, timelines. The output lands back in Supabase and renders as a unique page for that visitor. - + Someone fills out a form and gets a [custom marketing plan](https://www.brevo.com/tools/relationship-plan?utm_medium=web-external&utm_source=CTA_dust_blog&utm_campaign=en_supabase_blog_202602) in seconds. Data goes into Supabase, the AI builds the plan, the result comes back through @@ -130,16 +154,22 @@ The part of this story that stands out most is what happened after launch. Nothi Alexandre's team connected Supabase, documented their schema, tested the workflows, and moved on. They have not had to troubleshoot, reconfigure, or debug the connection since. - - We set it up, it worked, and we have not gone back. It just runs. That is exactly what you want from - infrastructure when you are a small ops team trying to ship fast. + + We set it up, it worked, and we have not gone back. It just runs. That is exactly what you want + from infrastructure when you are a small ops team trying to ship fast. Dust's data backs this up. Since the official integration launched in June 2025, Brevo has executed over 2,500 actions through the Supabase MCP. That includes parallel batch runs where dozens of AI conversations query and write to the database at the same time. - - Brevo runs parallel batch conversations making Supabase queries, and it works smoothly. The MCP has - been rock-solid since deployment with no incidents. + + Brevo runs parallel batch conversations making Supabase queries, and it works smoothly. The MCP + has been rock-solid since deployment with no incidents. Because Supabase is Postgres under the hood, extending it is simple. A new use case means a new table and an updated agent prompt. No re-architecture, no new integrations. @@ -150,7 +180,10 @@ Brevo's Revenue Operations team is not an engineering team. They do not write ba This is the pattern Supabase sees across enterprise innovation teams: when you give non-engineering builders a production-grade backend with AI-native tooling, they stop waiting and start shipping. - + What makes Supabase particularly exciting is its agility. It is lighter and faster to iterate with than traditional data platforms. For AI use cases, this speed matters: storing agent outputs, syncing workflows, generating content that feeds directly into production systems. It is perfectly @@ -161,7 +194,10 @@ This is the pattern Supabase sees across enterprise innovation teams: when you g Dust's engineering team points to three things that make Supabase work for agents. The remote MCP server is production-grade and easy to onboard. Read and write access closes the agentic loop, so agents produce durable outputs, not just answers. And non-technical teams own the entire data layer themselves, no engineering bottleneck. For agentic use cases where iteration speed is the advantage, that is a structural edge. - + What teams like Brevo's gain is not just speed, it is focus. When agents handle the data pulling, the personalization, the logging, the people can spend their time where it actually matters: strategy, relationships, decisions. That is the version of AI we are building toward. @@ -173,7 +209,10 @@ Alexandre estimates that 30% or more of the internal support requests his team f They are also scaling the email generation workflow to handle thousands of prospects in a single batch run. - + We started with one use case. Now we have three in production and more on the way. Every time we have a new idea, the first question is: what table do we need in Supabase? That is how fast we can move now. diff --git a/apps/www/_customers/hyper.mdx b/apps/www/_customers/hyper.mdx index cd8c89fc3279b..30907d1356c2d 100644 --- a/apps/www/_customers/hyper.mdx +++ b/apps/www/_customers/hyper.mdx @@ -47,11 +47,11 @@ Marketing is fragmented. Every platform — Meta, Google, Shopify, ESPs — has Without a unified action system and data layer, problems go unnoticed. A broken campaign. A budget overspend. A drop in conversions that nobody catches for weeks. - Whether you're a first-time founder or an agency managing hundreds of accounts, your data is spread - across every platform — and no one knows if something is broken until weeks later. Agents on Hyper - consolidate all of it and catch problems immediately, like an expert watching everything 24/7. AI is - changing how work gets done — people want to say 'launch this' and 'run analysis and email me the - report,' not do it themselves. + Whether you're a first-time founder or an agency managing hundreds of accounts, your data is + spread across every platform — and no one knows if something is broken until weeks later. Agents + on Hyper consolidate all of it and catch problems immediately, like an expert watching everything + 24/7. AI is changing how work gets done — people want to say 'launch this' and 'run analysis and + email me the report,' not do it themselves. ## Why they chose Supabase @@ -70,9 +70,9 @@ Beyond the database, Supabase gives Hyper a full platform to grow into. Auth, Re It's clear what Supabase is building for. They're building databases and features in the age of - super intelligence. Fantastic community, amazing developer documentation. If you're a startup looking - to build the next unicorn or just a lifestyle business, it's the easiest way to get started and - scale. + super intelligence. Fantastic community, amazing developer documentation. If you're a startup + looking to build the next unicorn or just a lifestyle business, it's the easiest way to get + started and scale. ## The solution @@ -127,9 +127,10 @@ Supabase for Platforms handles the backend infrastructure: per-customer database The vision goes further. If marketing agencies can spin up websites for their end clients through Hyper, all powered by Supabase for Platforms, that opens an entirely new competitive front. - With Supabase for Platforms, anyone can come into Hyper and launch a marketing agency from scratch. - Build a website, set up authentication and contact forms, create a funnel. In under 15 minutes you - have a live agency with AI agents that intake clients and run their marketing end to end. + With Supabase for Platforms, anyone can come into Hyper and launch a marketing agency from + scratch. Build a website, set up authentication and contact forms, create a funnel. In under 15 + minutes you have a live agency with AI agents that intake clients and run their marketing end to + end. That is what happens when you give a product team a platform that handles databases, auth, storage, and real-time out of the box. They stop building infrastructure and start building the thing they set out to build. diff --git a/apps/www/_go/events/accenture-reinvention-2026/contest.tsx b/apps/www/_go/events/accenture-reinvention-2026/contest.tsx index 2b472d103645f..3209290b4de4d 100644 --- a/apps/www/_go/events/accenture-reinvention-2026/contest.tsx +++ b/apps/www/_go/events/accenture-reinvention-2026/contest.tsx @@ -6,18 +6,19 @@ const page: GoPageInput = { template: 'lead-gen', slug: 'accenture-reinvention-2026/contest', metadata: { - title: 'Win an iPhone Pro Max | Supabase at Accenture AI & Data Conference (ReinventionX) 2026', + title: + 'Win an iPhone 17 Pro Max | Supabase at Accenture AI & Data Conference (ReinventionX) 2026', description: - 'Create a Supabase account and load data for a chance to win an iPhone Pro Max. Accenture AI & Data Conference (ReinventionX) 2026.', + 'Create a Supabase account and load data for a chance to win an iPhone 17 Pro Max. Accenture AI & Data Conference (ReinventionX) 2026.', }, hero: { - title: 'Win an iPhone Pro Max', + title: 'Win an iPhone 17 Pro Max', subtitle: 'Supabase at Accenture AI & Data Conference 2026', description: - 'Your team is already building with AI tools. Supabase is the production backend that turns those prototypes into secure, scalable applications. Try it out -- create an account, load some data, and you could win an iPhone Pro Max.', + 'Your team is already building with AI tools. Supabase is the production backend that turns those prototypes into secure, scalable applications. Try it out -- create an account, load some data, and you could win an iPhone 17 Pro Max.', image: { src: '/images/landing-pages/stripe-sessions/iphone17-pro-max.png', - alt: 'iPhone Pro Max', + alt: 'Orange iPhone 17 Pro Max', width: 400, height: 500, }, diff --git a/apps/www/_go/events/postgresconf-sjc-2026/contest.tsx b/apps/www/_go/events/postgresconf-sjc-2026/contest.tsx index 6684f2967dad6..0bbf24dbd7ed6 100644 --- a/apps/www/_go/events/postgresconf-sjc-2026/contest.tsx +++ b/apps/www/_go/events/postgresconf-sjc-2026/contest.tsx @@ -6,18 +6,18 @@ const page: GoPageInput = { template: 'lead-gen', slug: 'postgresconf-sjc-2026/contest', metadata: { - title: 'Win an iPhone Pro Max | Supabase at PostgresConf San Jose 2026', + title: 'Win an iPhone 17 Pro Max | Supabase at PostgresConf San Jose 2026', description: - 'Sign up for Supabase and enter the contest for a chance to win an iPhone Pro Max. PostgresConf San Jose 2026.', + 'Sign up for Supabase and enter the contest for a chance to win an iPhone 17 Pro Max. PostgresConf San Jose 2026.', }, hero: { - title: 'Win an iPhone Pro Max', + title: 'Win an iPhone 17 Pro Max', subtitle: 'Supabase at PostgresConf San Jose 2026', description: - 'Supabase is Postgres with batteries included -- auth, storage, edge functions, vectors, and real-time, all built on top of the database you already know. Sign up, load some data, and enter below for a chance to win an iPhone Pro Max.', + 'Supabase is Postgres with batteries included -- auth, storage, edge functions, vectors, and real-time, all built on top of the database you already know. Sign up, load some data, and enter below for a chance to win an iPhone 17 Pro Max.', image: { src: '/images/landing-pages/stripe-sessions/iphone17-pro-max.png', - alt: 'iPhone Pro Max', + alt: 'Orange iPhone 17 Pro Max', width: 400, height: 500, }, diff --git a/apps/www/_go/events/startup-grind-2026/contest.tsx b/apps/www/_go/events/startup-grind-2026/contest.tsx index e58211ca42f71..09bb47c022d21 100644 --- a/apps/www/_go/events/startup-grind-2026/contest.tsx +++ b/apps/www/_go/events/startup-grind-2026/contest.tsx @@ -6,18 +6,18 @@ const page: GoPageInput = { template: 'lead-gen', slug: 'startup-grind-2026/contest', metadata: { - title: 'Win an iPhone Pro Max | Supabase at Startup Grind 2026', + title: 'Win an iPhone 17 Pro Max | Supabase at Startup Grind 2026', description: - 'Create a Supabase account and load data for a chance to win an iPhone Pro Max. Startup Grind 2026.', + 'Create a Supabase account and load data for a chance to win an iPhone 17 Pro Max. Startup Grind 2026.', }, hero: { - title: 'Win an iPhone Pro Max', + title: 'Win an iPhone 17 Pro Max', subtitle: 'Supabase at Startup Grind 2026', description: - 'Great meeting you at Startup Grind. Supabase gives you Postgres with auth, storage, edge functions, and real-time -- everything you need to ship your product faster. Try it out and you could win an iPhone Pro Max.', + 'Great meeting you at Startup Grind. Supabase gives you Postgres with auth, storage, edge functions, and real-time -- everything you need to ship your product faster. Try it out and you could win an iPhone 17 Pro Max.', image: { src: '/images/landing-pages/stripe-sessions/iphone17-pro-max.png', - alt: 'iPhone Pro Max', + alt: 'Orange iPhone 17 Pro Max', width: 400, height: 500, }, @@ -37,7 +37,10 @@ const page: GoPageInput = { children: (
    -
  1. Create a Supabase account with the same email where you got our post-event note
  2. +
  3. + Create a Supabase account with the same email address where you got our post-event + note +
  4. Load data into a Supabase database
  5. Complete these steps by Monday, May 11, 2026 at 12:00 PM PST
diff --git a/apps/www/_go/events/stripe-sessions-2026/contest.tsx b/apps/www/_go/events/stripe-sessions-2026/contest.tsx index 41768a61c8cdc..21432184a0cb5 100644 --- a/apps/www/_go/events/stripe-sessions-2026/contest.tsx +++ b/apps/www/_go/events/stripe-sessions-2026/contest.tsx @@ -37,7 +37,10 @@ const page: GoPageInput = { children: (
    -
  1. Create a Supabase account with the same email where you got our post-event note
  2. +
  3. + Create a Supabase account with the same email address where you got our post-event + note +
  4. Load data into a Supabase database
  5. Complete these steps by Monday, May 11, 2026 at 12:00 PM PST
diff --git a/apps/www/_go/events/stripe-sessions-2026/exec-dinner.tsx b/apps/www/_go/events/stripe-sessions-2026/exec-dinner.tsx index d215de0af7dce..a0a2a22a0c06a 100644 --- a/apps/www/_go/events/stripe-sessions-2026/exec-dinner.tsx +++ b/apps/www/_go/events/stripe-sessions-2026/exec-dinner.tsx @@ -29,7 +29,7 @@ const page: GoPageInput = {

Spruce Restaurant

3640 Sacramento St, San Francisco, CA

-

Tuesday, April 29, 2026

+

Wednesday, April 29, 2026

6:30 PM -- Cocktails and introductions

7:00 PM -- Dinner and discussion