From dd57c1476f4a0cadf1a98bc492278dbffd09b3e9 Mon Sep 17 00:00:00 2001 From: Jeremias Menichelli Date: Wed, 2 Sep 2026 12:26:38 +0200 Subject: [PATCH 01/14] chore: Add redirects to www for kb project (#49780) --- apps/kb/astro.config.mjs | 1 + apps/kb/vercel.json | 3 ++- apps/www/.env.local.example | 1 + apps/www/lib/rewrites.js | 8 ++++++++ apps/www/turbo.jsonc | 1 + 5 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/kb/astro.config.mjs b/apps/kb/astro.config.mjs index 23662b95a674e..f44675364a7ed 100644 --- a/apps/kb/astro.config.mjs +++ b/apps/kb/astro.config.mjs @@ -37,6 +37,7 @@ const ssrLodashEs = { // https://astro.build/config export default defineConfig({ base: '/kb', + trailingSlash: 'ignore', integrations: [react()], vite: { ssr: { diff --git a/apps/kb/vercel.json b/apps/kb/vercel.json index 8df7e53755bba..44d765d2307ee 100644 --- a/apps/kb/vercel.json +++ b/apps/kb/vercel.json @@ -1,3 +1,4 @@ { - "buildCommand": "pnpm build" + "buildCommand": "pnpm build", + "redirects": [{ "source": "/", "destination": "/kb", "permanent": false }] } diff --git a/apps/www/.env.local.example b/apps/www/.env.local.example index 517bb89bd7b04..0c5e2947a5304 100644 --- a/apps/www/.env.local.example +++ b/apps/www/.env.local.example @@ -10,6 +10,7 @@ NEXT_PUBLIC_DOCS_URL=http://localhost:3005 NEXT_PUBLIC_ENVIRONMENT=local NEXT_PUBLIC_HCAPTCHA_SITE_KEY=10000000-ffff-ffff-ffff-000000000001 NEXT_PUBLIC_IS_PLATFORM=true +NEXT_PUBLIC_KB_URL=http://localhost:3008/kb NEXT_PUBLIC_MARKETPLACE_API_URL=https://fgxbxpvumhvzrhqngsyu.supabase.co NEXT_PUBLIC_MARKETPLACE_PUBLISHABLE_KEY=sb_publishable_VuF5ZvGqj6ODhZgN1J_vMw_YbiEs1R6 NEXT_PUBLIC_MISC_USE_ANON_KEY=sb_publishable_t45SVhgymMJOuamUXzJzPQ_sY-tSoUr diff --git a/apps/www/lib/rewrites.js b/apps/www/lib/rewrites.js index c11c0129b135f..6c7aba2f384c9 100644 --- a/apps/www/lib/rewrites.js +++ b/apps/www/lib/rewrites.js @@ -39,6 +39,14 @@ const rewrites = [ source: '/design-system/:path*', destination: `${process.env.NEXT_PUBLIC_DESIGN_SYSTEM_URL}/:path*`, }, + { + source: '/kb', + destination: `${process.env.NEXT_PUBLIC_KB_URL}`, + }, + { + source: '/kb/:path*', + destination: `${process.env.NEXT_PUBLIC_KB_URL}/:path*`, + }, { source: '/evals', destination: 'https://supabase-evals.vercel.app', diff --git a/apps/www/turbo.jsonc b/apps/www/turbo.jsonc index ea5a9af80f3b1..09078f1751445 100644 --- a/apps/www/turbo.jsonc +++ b/apps/www/turbo.jsonc @@ -21,6 +21,7 @@ "NEXT_PUBLIC_DOCS_URL", "NEXT_PUBLIC_REFERENCE_DOCS_URL", "NEXT_PUBLIC_LIBRARY_URL", + "NEXT_PUBLIC_KB_URL", // Temporary fallback during the production environment-variable migration. "NEXT_PUBLIC_UI_LIBRARY_URL", "NEXT_PUBLIC_SUPABASE_URL", From afeba62c7c92dca4075bbfd51559dce528e25ab7 Mon Sep 17 00:00:00 2001 From: David Camacho Cateura <47002519+dcamachoc@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:53:55 +0200 Subject: [PATCH 02/14] feat: Show min / max for integers in mgmt api docs (#49884) ## 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? Improvement in docs ## What is the current behavior? Not shown ## What is the new behavior? Displays min / max when available in OpenAPI specs for integers and numbers ## Additional context image ## Summary by CodeRabbit * **New Features** * API reference documentation now displays minimum and maximum constraints for numeric schema parameters, including `number` and `integer` types. --- apps/docs/features/docs/Reference.ui.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/docs/features/docs/Reference.ui.tsx b/apps/docs/features/docs/Reference.ui.tsx index c8a2ee5704b75..42c984a41e6c8 100644 --- a/apps/docs/features/docs/Reference.ui.tsx +++ b/apps/docs/features/docs/Reference.ui.tsx @@ -474,7 +474,9 @@ export function ApiSchemaParamSubdetails({ if ( !('enum' in schema) && 'type' in schema && - (['boolean', 'number', 'integer'].includes(schema.type) || + (schema.type === 'boolean' || + ((schema.type === 'number' || schema.type === 'integer') && + !('minimum' in schema || 'maximum' in schema)) || (schema.type === 'string' && !('minLength' in schema || 'maxLength' in schema || 'pattern' in schema)) || (schema.type === 'array' && @@ -501,7 +503,15 @@ export function ApiSchemaParamSubdetails({ constraint: key, value: schema[key], })) - : [] + : 'type' in schema && (schema.type === 'number' || schema.type === 'integer') + ? ['minimum', 'maximum'] + .filter((key) => key in schema) + .map((key) => ({ + constraint: key, + value: schema[key], + })) + : [] + const subContent = asSchemaArray(rawSubContent) return ( @@ -575,7 +585,10 @@ export function ApiSchemaParamSubdetails({ {String(detail)} - ) : 'type' in schema && schema.type === 'string' ? ( + ) : 'type' in schema && + (schema.type === 'string' || + schema.type === 'number' || + schema.type === 'integer') ? ( {detail.constraint} From 5db8a0e960cb9d573de0f49c5711523feb34fd9c Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Wed, 2 Sep 2026 19:02:37 +0800 Subject: [PATCH 03/14] feat(studio): instrument sign-in attempts and failures (#49853) The /sign-in page emitted only a pageview on entry and the success-side `sign_in` event on exit: failed or abandoned attempts were invisible, so "never interacted" and "tried and failed silently" could not be told apart in the sign-in funnel. I added an unsampled `sign_in_submitted` event at every initiation point and classified failure capture via `dashboard_error_created` with a new `signin` origin. **Changed:** - **Submit attempts observable**: `sign_in_submitted` (method: `email`, provider id, `sso`, or partner) fires from the DOM submit handler on the password and SSO forms (so submits that fail client-side validation still count), and from the OAuth, custom-provider, and partner initiation handlers. - **Failures classified**: each sign-in error path feeds the existing funnel-error pipe with origin `signin` and a controlled reason slug (`invalid_credentials`, `email_not_confirmed`, `captcha_failed`, `sso_provider_not_found`, ...). GoTrue auth errors now classify via their numeric `status`, guarded so transport failures (`status: 0`) stay `network_error`. - **Attempt events survive the OAuth redirect**: the telemetry event POST sends with `keepalive` (scoped to `sign_in_submitted`, since keepalive requests share a per-page in-flight body quota), so a dispatched request is no longer aborted by the provider navigation; send rejections are caught centrally instead of surfacing as unhandled rejections. The fetch still dispatches after an async token lookup, so preview testing verifies the GitHub-path event actually lands on the wire. - **Captcha rejection is no longer silent**: a rejected hCaptcha challenge resolves the stuck loading toast with an error message, emits `captcha_challenge_failed` (distinct from `captcha_failed`, which stays reserved for the auth server rejecting a submitted token), reports to error monitoring, and resets the captcha widget (previously: unhandled promise rejection and a spinner that never resolved). - **Partner method validated**: the partner sign-in page resolves the URL-hash value against the provider registry and forwards the canonical provider id into `method` on both `sign_in_submitted` and `sign_in`; anything unregistered records as `unregistered_partner`, so a crafted link can't poison the breakdown on either event. **Note:** failure events stay on the shared 10% `dashboard_error_created` sampling rate (a per-origin carve-out would break cross-source volume comparability); the unsampled attempt event carries the tried-vs-never-interacted signal at full volume. ## To test Tested on Vercel preview (studio-staging, wire-level network capture + staging ingestion check): - [x] On `/sign-in`, submit a bogus email + password: expect a `POST */platform/telemetry/event` request with `action: sign_in_submitted`, `method: email` in the network tab, plus an error toast. Observed: 201, auth returned 400 as expected. - [x] Submit with an empty password: expect `sign_in_submitted` to still fire (validation failures count as attempts). Observed: event fired with 201 and no auth call followed. - [x] Click "Continue with GitHub": expect `sign_in_submitted` with `method: github` on the wire before the provider redirect. Observed: the POST completed (201) before the browser landed on github.com, so the keepalive path holds. - [x] Negative case: fresh page load with no interaction fires no `sign_in_submitted`. - [x] Ingestion: all fired events (methods `email`, `github`, plus organic `sso` submits from a real login on the same preview) arrived in the staging project with the expected properties. - [x] Re-ran the email and GitHub paths on the scoped-keepalive build (`129bf8d`): both `sign_in_submitted` POSTs returned 201 (the GitHub one completed despite the provider redirect), and both events ingested into the staging project with the expected `method`/`category` properties. ## Linear - GROWTH-1165 (no `fixes` keyword on purpose: the evidence checks run on prod data post-deploy, and the issue closes manually after they pass) ## Summary by CodeRabbit * **New Features** * Improved sign-in protection with more reliable invisible CAPTCHA handling. * Added sign-in submission tracking across password, SSO, partner, custom OAuth, and external-provider flows. * Added detailed classification for authentication, validation, CAPTCHA, provider, and network errors. * **Bug Fixes** * Sign-in now stops safely and resets CAPTCHA when verification fails. * Improved error reporting for failed sign-in attempts, including redirects and OAuth flows. * Ensured sign-in telemetry is delivered reliably during OAuth redirects. --- .../interfaces/SignIn/SignIn.utils.ts | 32 +++++++ .../interfaces/SignIn/SignInForm.tsx | 26 +++++- .../interfaces/SignIn/SignInPartner.tsx | 21 ++++- .../interfaces/SignIn/SignInSSOForm.tsx | 23 ++++- .../interfaces/SignIn/SignInWithCustom.tsx | 9 +- .../SignIn/SignInWithExternalProvider.tsx | 9 +- .../lib/telemetry/funnel-errors.test.ts | 87 +++++++++++++++++++ apps/studio/lib/telemetry/funnel-errors.ts | 29 ++++++- apps/studio/lib/toast-errors.test.tsx | 27 ++++++ packages/common/telemetry-constants.ts | 26 +++++- packages/common/telemetry.tsx | 8 ++ 11 files changed, 280 insertions(+), 17 deletions(-) create mode 100644 apps/studio/components/interfaces/SignIn/SignIn.utils.ts diff --git a/apps/studio/components/interfaces/SignIn/SignIn.utils.ts b/apps/studio/components/interfaces/SignIn/SignIn.utils.ts new file mode 100644 index 0000000000000..e9229aa3aec44 --- /dev/null +++ b/apps/studio/components/interfaces/SignIn/SignIn.utils.ts @@ -0,0 +1,32 @@ +import type HCaptcha from '@hcaptcha/react-hcaptcha' +import type { RefObject } from 'react' +import { toast } from 'sonner' + +import { captureCriticalError } from '@/lib/error-reporting' +import type { useTrackFunnelError } from '@/lib/telemetry/use-track-funnel-error' + +type TrackFunnelError = ReturnType + +export async function resolveCaptchaToken( + captchaRef: RefObject, + trackFunnelError: TrackFunnelError, + toastId: string | number +): Promise<{ ok: true; token: string | null } | { ok: false }> { + try { + const captchaResponse = await captchaRef.current?.execute({ async: true }) + return { ok: true, token: captchaResponse?.response ?? null } + } catch (error) { + toast.error('Could not complete the security check. Please try again.', { id: toastId }) + trackFunnelError( + 'signin', + { errorCategory: 'unknown', errorReason: 'captcha_challenge_failed' }, + 'toast', + toastId + ) + captureCriticalError( + error instanceof Error ? error : new Error(String(error)), + 'sign in captcha challenge' + ) + return { ok: false } + } +} diff --git a/apps/studio/components/interfaces/SignIn/SignInForm.tsx b/apps/studio/components/interfaces/SignIn/SignInForm.tsx index bf0549a81c4cc..258af90808ae9 100644 --- a/apps/studio/components/interfaces/SignIn/SignInForm.tsx +++ b/apps/studio/components/interfaces/SignIn/SignInForm.tsx @@ -14,13 +14,16 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import z from 'zod' import { LastSignInWrapper } from './LastSignInWrapper' +import { resolveCaptchaToken } from './SignIn.utils' import { AlertError } from '@/components/ui/AlertError' import { useAddLoginEvent } from '@/data/misc/audit-login-mutation' import { getMfaAuthenticatorAssuranceLevel } from '@/data/profile/mfa-authenticator-assurance-level-query' import { useLastSignIn } from '@/hooks/misc/useLastSignIn' import { captureCriticalError } from '@/lib/error-reporting' import { auth, buildPathWithParams, getReturnToPath } from '@/lib/gotrue' +import { classifyApiError, classifyValidationError } from '@/lib/telemetry/funnel-errors' import { useTrack } from '@/lib/telemetry/track' +import { useTrackFunnelError } from '@/lib/telemetry/use-track-funnel-error' const schema = z.object({ email: z.string().min(1, 'Email is required').email('Must be a valid email'), @@ -51,6 +54,7 @@ export const SignInForm = () => { }, []) const track = useTrack() + const trackFunnelError = useTrackFunnelError() const { mutate: addLoginEvent } = useAddLoginEvent() let forgotPasswordUrl = `/forgot-password` @@ -64,8 +68,13 @@ export const SignInForm = () => { let token = captchaToken if (!token) { - const captchaResponse = await captchaRef.current?.execute({ async: true }) - token = captchaResponse?.response ?? null + const captcha = await resolveCaptchaToken(captchaRef, trackFunnelError, toastId) + if (!captcha.ok) { + setCaptchaToken(null) + captchaRef.current?.resetCaptcha() + return + } + token = captcha.token } const { error } = await auth.signInWithPassword({ @@ -100,6 +109,7 @@ export const SignInForm = () => { router.push(redirectPath) } catch (error: any) { toast.error(`Failed to sign in: ${(error as AuthError).message}`, { id: toastId }) + trackFunnelError('signin', classifyApiError('signin', error), 'toast', toastId) captureCriticalError(error, 'sign in via EP') } } else { @@ -107,13 +117,16 @@ export const SignInForm = () => { captchaRef.current?.resetCaptcha() if (error.message.toLowerCase() === 'email not confirmed') { - return toast.error( + toast.error( 'Your account has not been verified. Please check the verification link sent to your email. If you have not received the email or the link has expired, please sign up again to request a new verification link.', { id: toastId } ) + trackFunnelError('signin', classifyApiError('signin', error), 'toast', toastId) + return } toast.error(error.message, { id: toastId }) + trackFunnelError('signin', classifyApiError('signin', error), 'toast', toastId) } } @@ -125,7 +138,12 @@ export const SignInForm = () => { id={formId} method="POST" className="flex flex-col gap-4" - onSubmit={form.handleSubmit(onSubmit)} + onSubmit={(e) => { + track('sign_in_submitted', { category: 'account', method: 'email' }) + return form.handleSubmit(onSubmit, (errors) => + trackFunnelError('signin', classifyValidationError('signin', errors), 'form') + )(e) + }} > {authError && } { const router = useRouter() + const track = useTrack() + const trackFunnelError = useTrackFunnelError() useEffect(() => { ;(async () => { @@ -18,10 +24,21 @@ export const SignInPartner = () => { const { data } = await auth.getSession() if (!data.session && partner && token) { + // partner comes from the URL hash unauthenticated; only registry-known values may + // enter the method vocabulary, anything else would let a crafted link poison it + const knownPartner = getIdentityProviderConfig(partner) + const method = knownPartner?.id ?? 'unregistered_partner' + track('sign_in_submitted', { + category: 'account', + method, + }) try { - await auth.signInWithIdToken({ provider: partner, token }) + const { error } = await auth.signInWithIdToken({ provider: partner, token }) + if (error) { + trackFunnelError('signin', classifyApiError('signin', error), 'form') + } } finally { - router.replace({ pathname: '/sign-in-mfa', query: { method: partner } }) + router.replace({ pathname: '/sign-in-mfa', query: { method } }) } } else { router.replace({ pathname: '/sign-in' }) diff --git a/apps/studio/components/interfaces/SignIn/SignInSSOForm.tsx b/apps/studio/components/interfaces/SignIn/SignInSSOForm.tsx index f6e42e82ce4c7..908f42e204c62 100644 --- a/apps/studio/components/interfaces/SignIn/SignInSSOForm.tsx +++ b/apps/studio/components/interfaces/SignIn/SignInSSOForm.tsx @@ -12,10 +12,14 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import z from 'zod' import { LastSignInWrapper } from './LastSignInWrapper' +import { resolveCaptchaToken } from './SignIn.utils' import { useLastSignIn } from '@/hooks/misc/useLastSignIn' import { BASE_PATH } from '@/lib/constants' import { captureCriticalError } from '@/lib/error-reporting' import { auth, buildPathWithParams } from '@/lib/gotrue' +import { classifyApiError, classifyValidationError } from '@/lib/telemetry/funnel-errors' +import { useTrack } from '@/lib/telemetry/track' +import { useTrackFunnelError } from '@/lib/telemetry/use-track-funnel-error' const schema = z.object({ email: z.string().min(1, 'Email is required').email('Must be a valid email'), @@ -28,6 +32,8 @@ export const SignInSSOForm = () => { const captchaRef = useRef(null) const [captchaToken, setCaptchaToken] = useState(null) const [_, setLastSignInUsed] = useLastSignIn() + const track = useTrack() + const trackFunnelError = useTrackFunnelError() const form = useForm>({ resolver: zodResolver(schema), defaultValues: { email: '' }, @@ -39,8 +45,13 @@ export const SignInSSOForm = () => { let token = captchaToken if (!token) { - const captchaResponse = await captchaRef.current?.execute({ async: true }) - token = captchaResponse?.response ?? null + const captcha = await resolveCaptchaToken(captchaRef, trackFunnelError, toastId) + if (!captcha.ok) { + setCaptchaToken(null) + captchaRef.current?.resetCaptcha() + return + } + token = captcha.token } // redirects to /sign-in to check if the user has MFA setup (handled in SignInLayout.tsx) @@ -71,6 +82,7 @@ export const SignInSSOForm = () => { setCaptchaToken(null) captchaRef.current?.resetCaptcha() toast.error(`Failed to sign in: ${error.message}`, { id: toastId }) + trackFunnelError('signin', classifyApiError('signin', error), 'toast', toastId) captureCriticalError(error, 'sign in via SSO') } } @@ -81,7 +93,12 @@ export const SignInSSOForm = () => { id={formId} method="POST" className="flex flex-col gap-4" - onSubmit={form.handleSubmit(onSubmit)} + onSubmit={(e) => { + track('sign_in_submitted', { category: 'account', method: 'sso' }) + return form.handleSubmit(onSubmit, (errors) => + trackFunnelError('signin', classifyValidationError('signin', errors), 'form') + )(e) + }} > { const [loading, setLoading] = useState(false) const displayName = getProviderDisplay(providerName).displayName + const track = useTrack() + const trackFunnelError = useTrackFunnelError() async function handleCustomSignIn() { setLoading(true) + track('sign_in_submitted', { category: 'account', method: providerName.toLowerCase() }) try { // redirects to /sign-in to check if the user has MFA setup (handled in SignInLayout.tsx) @@ -36,7 +42,8 @@ export const SignInWithCustom = ({ providerName }: SignInWithCustomProps) => { if (error) throw error } catch (error: any) { - toast.error(`Failed to sign in via ${displayName}: ${error.message}`) + const toastId = toast.error(`Failed to sign in via ${displayName}: ${error.message}`) + trackFunnelError('signin', classifyApiError('signin', error), 'toast', toastId) captureCriticalError(error, `sign in via ${providerName}`) setLoading(false) } diff --git a/apps/studio/components/interfaces/SignIn/SignInWithExternalProvider.tsx b/apps/studio/components/interfaces/SignIn/SignInWithExternalProvider.tsx index 51ed1debe9a7c..bf3ee81a69c7a 100644 --- a/apps/studio/components/interfaces/SignIn/SignInWithExternalProvider.tsx +++ b/apps/studio/components/interfaces/SignIn/SignInWithExternalProvider.tsx @@ -14,6 +14,9 @@ import { } from '@/lib/external-identity-providers' import { getErrorMessage } from '@/lib/get-error-message' import { auth, buildPathWithParams } from '@/lib/gotrue' +import { classifyApiError } from '@/lib/telemetry/funnel-errors' +import { useTrack } from '@/lib/telemetry/track' +import { useTrackFunnelError } from '@/lib/telemetry/use-track-funnel-error' interface SignInWithExternalProviderProps { provider: ExternalIdentityProviderConfig @@ -22,9 +25,12 @@ interface SignInWithExternalProviderProps { export const SignInWithExternalProvider = ({ provider }: SignInWithExternalProviderProps) => { const [loading, setLoading] = useState(false) const [, setLastSignInUsed] = useLastSignIn() + const track = useTrack() + const trackFunnelError = useTrackFunnelError() async function handleSignIn() { setLoading(true) + track('sign_in_submitted', { category: 'account', method: provider.id }) try { // Redirects to /sign-in-mfa to check if the user has MFA set up before entering the dashboard @@ -40,7 +46,8 @@ export const SignInWithExternalProvider = ({ provider }: SignInWithExternalProvi setLastSignInUsed(provider.id) } catch (error: unknown) { const message = getErrorMessage(error) ?? 'Unknown error' - toast.error(`Failed to sign in via ${provider.displayName}: ${message}`) + const toastId = toast.error(`Failed to sign in via ${provider.displayName}: ${message}`) + trackFunnelError('signin', classifyApiError('signin', error), 'toast', toastId) captureCriticalError( error instanceof Error ? error : new Error(message), `sign in via ${provider.displayName}` diff --git a/apps/studio/lib/telemetry/funnel-errors.test.ts b/apps/studio/lib/telemetry/funnel-errors.test.ts index 6abe0049b6d73..161a1c79df08e 100644 --- a/apps/studio/lib/telemetry/funnel-errors.test.ts +++ b/apps/studio/lib/telemetry/funnel-errors.test.ts @@ -58,6 +58,84 @@ describe('classifyApiError', () => { errorCode: 400, }) }) + + describe('signin', () => { + it('reads a GoTrue AuthError status as the code', () => { + expect( + classifyApiError('signin', { status: 400, message: 'Invalid login credentials' }) + ).toEqual({ + errorCategory: 'api', + errorReason: 'invalid_credentials', + errorCode: 400, + }) + }) + + it('classifies an unconfirmed email', () => { + expect(classifyApiError('signin', { status: 400, message: 'Email not confirmed' })).toEqual({ + errorCategory: 'api', + errorReason: 'email_not_confirmed', + errorCode: 400, + }) + }) + + it('classifies 429 via status as rate_limited', () => { + expect(classifyApiError('signin', { status: 429, message: 'Rate limit exceeded' })).toEqual({ + errorCategory: 'api', + errorReason: 'rate_limited', + errorCode: 429, + }) + }) + + it('classifies a captcha failure', () => { + expect( + classifyApiError('signin', { status: 400, message: 'captcha verification process failed' }) + ).toEqual({ errorCategory: 'api', errorReason: 'captcha_failed', errorCode: 400 }) + }) + + it('matches the SSO pattern before the 404 status map', () => { + expect( + classifyApiError('signin', { + status: 404, + message: 'No SSO provider assigned for this domain', + }) + ).toEqual({ errorCategory: 'api', errorReason: 'sso_provider_not_found', errorCode: 404 }) + }) + + it('classifies a redirect allow-list rejection', () => { + expect(classifyApiError('signin', { status: 400, message: 'Invalid redirect URL' })).toEqual({ + errorCategory: 'api', + errorReason: 'redirect_not_allowed', + errorCode: 400, + }) + }) + + it('classifies a disabled provider', () => { + expect( + classifyApiError('signin', { + status: 400, + message: 'Unsupported provider: provider is not enabled', + }) + ).toEqual({ errorCategory: 'api', errorReason: 'provider_not_enabled', errorCode: 400 }) + }) + + it('classifies a GoTrue transport failure (status 0) as network_error, never api/other', () => { + expect( + classifyApiError('signin', { + name: 'AuthRetryableFetchError', + status: 0, + message: 'Failed to fetch', + }) + ).toEqual({ errorCategory: 'network', errorReason: 'network_error' }) + }) + + it('classifies a retryable 5xx via status as server_error', () => { + expect(classifyApiError('signin', { status: 503, message: 'Service unavailable' })).toEqual({ + errorCategory: 'api', + errorReason: 'server_error', + errorCode: 503, + }) + }) + }) }) describe('classifyValidationError', () => { @@ -79,6 +157,15 @@ describe('classifyValidationError', () => { ).toEqual({ errorCategory: 'validation', errorReason: 'email_invalid' }) }) + it('maps a signin password error to password_invalid', () => { + expect( + classifyValidationError('signin', { password: { type: 'too_small' } } as FieldErrors) + ).toEqual({ + errorCategory: 'validation', + errorReason: 'password_invalid', + }) + }) + it('maps an org name error to org_name_missing', () => { expect( classifyValidationError('org_creation', { name: { type: 'too_small' } } as FieldErrors) diff --git a/apps/studio/lib/telemetry/funnel-errors.ts b/apps/studio/lib/telemetry/funnel-errors.ts index 67c92592fed41..d4873ae20398f 100644 --- a/apps/studio/lib/telemetry/funnel-errors.ts +++ b/apps/studio/lib/telemetry/funnel-errors.ts @@ -1,6 +1,6 @@ import type { FieldErrors } from 'react-hook-form' -export type FunnelOrigin = 'signup' | 'project_creation' | 'org_creation' +export type FunnelOrigin = 'signup' | 'signin' | 'project_creation' | 'org_creation' export type ErrorCategory = 'validation' | 'api' | 'network' | 'payment' | 'unknown' export interface FunnelErrorClassification { @@ -19,6 +19,16 @@ const API_REASON_PATTERNS = { [/password/i, 'password_rejected'], [/valid email|invalid email|email address/i, 'email_invalid'], ], + signin: [ + [/invalid login credentials/i, 'invalid_credentials'], + [/email not confirmed/i, 'email_not_confirmed'], + [/rate limit|too many requests|after \d+ second/i, 'rate_limited'], + [/captcha/i, 'captcha_failed'], + [/sso provider/i, 'sso_provider_not_found'], + [/redirect|requested path is invalid/i, 'redirect_not_allowed'], + [/provider is not enabled|unsupported provider/i, 'provider_not_enabled'], + [/valid email|invalid email|email address/i, 'email_invalid'], + ], project_creation: [ [/already exists/i, 'project_name_taken'], [/free plan|free tier/i, 'free_tier_limit'], @@ -39,6 +49,10 @@ const VALIDATION_FIELD_REASONS = { email: 'email_invalid', password: 'password_invalid', }, + signin: { + email: 'email_invalid', + password: 'password_invalid', + }, project_creation: { organization: 'organization_missing', projectName: 'project_name_invalid', @@ -67,6 +81,7 @@ const STRIPE_DECLINE_REASONS = { } as const satisfies Record const GENERIC_REASONS = [ + 'captcha_challenge_failed', 'rate_limited', 'server_error', 'connection_timeout', @@ -95,8 +110,16 @@ const STATUS_REASONS: Readonly>> = { } export function classifyApiError(origin: FunnelOrigin, error: unknown): FunnelErrorClassification { - const err = error as { code?: unknown; errorType?: unknown; message?: unknown } - const code = typeof err?.code === 'number' ? err.code : undefined + const err = error as { code?: unknown; status?: unknown; errorType?: unknown; message?: unknown } + // GoTrue AuthErrors carry a numeric `status` and a string `code` slug; auth-js uses + // status 0 for transport failures (AuthRetryableFetchError), which must classify as + // network_error, so the fallback only accepts positive statuses. + const code = + typeof err?.code === 'number' + ? err.code + : typeof err?.status === 'number' && err.status > 0 + ? err.status + : undefined const message = typeof err?.message === 'string' ? err.message : '' if (err?.errorType === 'connection-timeout') { diff --git a/apps/studio/lib/toast-errors.test.tsx b/apps/studio/lib/toast-errors.test.tsx index 07e0971d3adaf..60f0141b9d1e9 100644 --- a/apps/studio/lib/toast-errors.test.tsx +++ b/apps/studio/lib/toast-errors.test.tsx @@ -76,6 +76,33 @@ describe('ToastErrorTracker', () => { expect(mockTrack).toHaveBeenCalledTimes(1) }) + it('tracks a loading toast updated to an error exactly once (sign-in reuses the loading toast id)', async () => { + render() + let toastId: string | number + act(() => { + toastId = toast.loading('Signing in...') + }) + act(() => { + toast.error('Invalid login credentials', { id: toastId }) + registerFunnelErrorToast(toastId, { + origin: 'signin', + errorCategory: 'api', + errorReason: 'invalid_credentials', + errorCode: 400, + }) + }) + await waitFor(() => + expect(mockTrack).toHaveBeenCalledWith('dashboard_error_created', { + source: 'toast', + origin: 'signin', + errorCategory: 'api', + errorReason: 'invalid_credentials', + errorCode: 400, + }) + ) + expect(mockTrack).toHaveBeenCalledTimes(1) + }) + it('ignores non-error toasts', async () => { render() act(() => { diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 2b7ff3134ac4b..0e4365ed54eac 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -50,8 +50,7 @@ export interface SignUpEvent { * * Some unintuitive behavior: * - If signing up with GitHub the SignInEvent gets triggered first before the SignUpEvent. - * - Captured server-side; the distinct_id often resolves to the anonymous cookie because - * the event races identify, so don't use it as a funnel join key across the auth boundary. + * - distinct_id often resolves to the anonymous cookie (races identify); not a person-level join key. * * @group Events * @source studio @@ -68,6 +67,26 @@ export interface SignInEvent { } } +/** + * Triggered when a user initiates a sign-in (form submit including client-side validation + * failures, OAuth or custom-provider click, partner token exchange), before auth resolves. + * Pre-auth, so distinct_id is the anonymous cookie: not a person-level join key. + * + * @group Events + * @source studio + * @page /sign-in, /sign-in-sso, /sign-in-partner + */ +export interface SignInSubmittedEvent { + action: 'sign_in_submitted' + properties: { + category: 'account' + /** + * Matches the sign_in event's method vocabulary, e.g. email (password path), github, sso + */ + method: string + } +} + /** * User copied the database connection string. * @@ -3176,7 +3195,7 @@ export interface DashboardErrorCreatedEvent { /** * Funnel the error occurred in (set only for instrumented funnel errors) */ - origin?: 'signup' | 'project_creation' | 'org_creation' + origin?: 'signup' | 'signin' | 'project_creation' | 'org_creation' /** * Coarse classification of the funnel error */ @@ -3772,6 +3791,7 @@ export interface HeaderLocalVersionPopoverOpenedEvent { export type TelemetryEvent = | SignUpEvent | SignInEvent + | SignInSubmittedEvent | ConnectionStringCopiedEvent | McpInstallButtonClickedEvent | ApiDocsOpenedEvent diff --git a/packages/common/telemetry.tsx b/packages/common/telemetry.tsx index 4e8e13aa9f897..f3cce2f62862f 100644 --- a/packages/common/telemetry.tsx +++ b/packages/common/telemetry.tsx @@ -428,8 +428,16 @@ export function sendTelemetryEvent(API_URL: string, event: TelemetryEvent, pathn } } + // keepalive lets the request survive the same-tick OAuth redirect after + // sign_in_submitted, but keepalive requests share a ~64KB in-flight quota + // page-wide, so it stays scoped to that event. Callers like useTrack + // fire-and-forget, so rejections are handled here rather than surfacing + // as unhandled promise rejections. return post(`${ensurePlatformSuffix(API_URL)}/telemetry/event`, body, { headers: { Version: '2' }, + keepalive: event.action === 'sign_in_submitted', + }).catch((error) => { + console.error('Problem sending telemetry event:', error) }) } From 6e83f71a563aa60a531ddd71924f1c2a9a9efe31 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Wed, 2 Sep 2026 14:06:23 +0300 Subject: [PATCH 04/14] docs: wire middleware sdk docs (#49854) Wire middleware sdk docs (`@supabase/middleware`) https://github.com/supabase/middleware Preview ref here: https://docs-git-docs-supabase-middleware-sdk-supabase.vercel.app/docs/reference/middleware/introduction ## Summary by CodeRabbit * **New Features** * Added a Middleware SDK reference section to the documentation. * Added installation guidance for npm, Yarn, pnpm, Deno, and Bun. * Documented framework-agnostic middleware composition, typed shared context, ordering, trust, and environment access across supported runtimes. * Added Middleware documentation to navigation and search. * Identified the Middleware SDK as an alpha release. --- .../NavigationMenu.constants.ts | 18 ++++ .../NavigationMenu/NavigationMenu.tsx | 6 ++ apps/docs/content/navigation.references.ts | 14 ++++ apps/docs/docs/ref/middleware/installing.mdx | 84 +++++++++++++++++++ .../docs/docs/ref/middleware/introduction.mdx | 26 ++++++ .../docs/features/docs/Reference.constants.ts | 7 +- .../internals/generate-reference-markdown.ts | 7 ++ apps/docs/layouts/MainSkeleton.tsx | 4 + apps/docs/package.json | 2 +- apps/docs/scripts/search/sources/index.ts | 14 ++++ apps/docs/spec/Makefile | 7 +- .../spec/reference/middleware/v1/config.json | 5 ++ .../middleware/v1/partials/installing.mdx | 84 +++++++++++++++++++ .../middleware/v1/partials/introduction.mdx | 26 ++++++ 14 files changed, 300 insertions(+), 4 deletions(-) create mode 100644 apps/docs/docs/ref/middleware/installing.mdx create mode 100644 apps/docs/docs/ref/middleware/introduction.mdx create mode 100644 apps/docs/spec/reference/middleware/v1/config.json create mode 100644 apps/docs/spec/reference/middleware/v1/partials/installing.mdx create mode 100644 apps/docs/spec/reference/middleware/v1/partials/introduction.mdx diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 28501353fc0f4..2afa1d2bf3536 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -285,6 +285,13 @@ export const GLOBAL_MENU_ITEMS: GlobalMenuItems = [ level: 'reference_server', new: true, }, + { + label: 'Middleware SDK', + icon: 'reference-javascript', + href: '/reference/middleware' as `/${string}`, + level: 'reference_middleware', + new: true, + }, { label: 'CLI Commands', icon: 'reference-cli', @@ -3432,6 +3439,17 @@ export const reference_server_v1 = { }, } +export const reference_middleware_v1 = { + icon: 'reference-javascript', + title: 'Middleware', + url: '/reference/middleware', + parent: '/reference', + pkg: { + name: '@supabase/middleware', + repo: 'https://github.com/supabase/middleware', + }, +} + // TODO: How to? export const reference_dart_v1 = { icon: 'reference-dart', diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.tsx b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.tsx index 73bcfdc175c7b..5d6064525604e 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.tsx @@ -28,6 +28,7 @@ enum MenuId { LocalDevelopment = 'local_development', Contributing = 'contributing', RefServerV1 = 'reference_server_v1', + RefMiddlewareV1 = 'reference_middleware_v1', RefJavaScriptV1 = 'reference_javascript_v1', RefJavaScriptV2 = 'reference_javascript_v2', RefDartV1 = 'reference_dart_v1', @@ -153,6 +154,11 @@ const menus: Menu[] = [ type: 'reference', path: '/reference/server', }, + { + id: MenuId.RefMiddlewareV1, + type: 'reference', + path: '/reference/middleware', + }, { id: MenuId.RefJavaScriptV1, type: 'reference', diff --git a/apps/docs/content/navigation.references.ts b/apps/docs/content/navigation.references.ts index 92c56d6b60c7c..6bbb9082f6cda 100644 --- a/apps/docs/content/navigation.references.ts +++ b/apps/docs/content/navigation.references.ts @@ -44,6 +44,20 @@ export const REFERENCES = { }, }, }, + middleware: { + type: 'sdk', + name: 'Middleware', + library: '@supabase/middleware', + libPath: 'middleware', + versions: ['v1'], + typeSpec: true, + icon: 'reference-javascript', + meta: { + v1: { + libId: 'reference_middleware_v1', + }, + }, + }, dart: { type: 'sdk', name: 'Flutter', diff --git a/apps/docs/docs/ref/middleware/installing.mdx b/apps/docs/docs/ref/middleware/installing.mdx new file mode 100644 index 0000000000000..dd4da3ab121dd --- /dev/null +++ b/apps/docs/docs/ref/middleware/installing.mdx @@ -0,0 +1,84 @@ +--- +id: installing +title: Installing +slug: installing +--- + +### Install as a package + + + + + Install `@supabase/middleware` via your package manager. + + + + + + + + + ```sh Terminal + npm install @supabase/middleware + ``` + + + + + ```sh Terminal + yarn add @supabase/middleware + ``` + + + + + ```sh Terminal + pnpm add @supabase/middleware + ``` + + + + + + + +### Use via JSR (Deno / Bun) + + + + + `@supabase/middleware` is also published to [JSR](https://jsr.io/@supabase/middleware) for Deno and Bun environments. + + + + + + + + + ```sh Terminal + deno add jsr:@supabase/middleware + ``` + + + + + ```sh Terminal + bunx jsr add @supabase/middleware + ``` + + + + + + diff --git a/apps/docs/docs/ref/middleware/introduction.mdx b/apps/docs/docs/ref/middleware/introduction.mdx new file mode 100644 index 0000000000000..a73f26d31622d --- /dev/null +++ b/apps/docs/docs/ref/middleware/introduction.mdx @@ -0,0 +1,26 @@ +--- +id: introduction +title: Introduction +--- + +`@supabase/middleware` is a framework-agnostic engine for composing server-side request middleware. You write small units with `defineMiddleware`, compose them with `pipeline`, and run the result as a standard Fetch handler on Node, Deno, Bun, and Cloudflare Workers. + +Each middleware can guard the request, edit the response, or contribute typed values to a shared context that later middleware and your handler read. TypeScript checks the composition: a middleware that needs a value from an earlier middleware will not compile unless that middleware runs first. + + + +`@supabase/middleware` is in alpha. APIs may change between 0.x releases. The `middleware` option on `withSupabase` in `@supabase/server` is also alpha. + + + +This package contains the engine and the framework-neutral middleware: `withCors` and `withFeatureFlag`. Supabase-specific middleware such as `withClaims`, `withSupabaseClient`, `withSupabaseAdminClient`, `withPostgresClient`, and `withPostgresAdminClient` ships in [`@supabase/server`](/docs/reference/server/introduction). + +### Trust model + +A middleware in your pipeline runs with the same access as your own handler code. It sees the full request, everything earlier middleware put on the context, and every response on the way out. Nothing sandboxes it. Only compose middleware you trust as much as your own code. + +Ordering is your permission boundary. A middleware can only read context values that earlier entries contributed, so place third-party middleware as early as possible and sensitive contributions as late as possible. + +### Environment access + +Middleware reads configuration through `getEnv` instead of `process.env`. On Node, Deno, and Bun, `getEnv` reads the process environment. Cloudflare Workers pass env bindings per request instead of exposing a global environment; the pipeline seeds each request's bindings, and `getEnv` reads the ones for the current request. diff --git a/apps/docs/features/docs/Reference.constants.ts b/apps/docs/features/docs/Reference.constants.ts index ec3e861fb90d5..70a04b0d174e7 100644 --- a/apps/docs/features/docs/Reference.constants.ts +++ b/apps/docs/features/docs/Reference.constants.ts @@ -16,4 +16,9 @@ * not listed here keeps reading from the legacy `features/docs/generated/` * outputs. */ -export const SUPPORTS_NEW_REFERENCE_PROCESS = new Set(['javascript-v2', 'dart-v2', 'server-v1']) +export const SUPPORTS_NEW_REFERENCE_PROCESS = new Set([ + 'javascript-v2', + 'dart-v2', + 'server-v1', + 'middleware-v1', +]) diff --git a/apps/docs/internals/generate-reference-markdown.ts b/apps/docs/internals/generate-reference-markdown.ts index 8f18dabc28619..d985a788e1259 100644 --- a/apps/docs/internals/generate-reference-markdown.ts +++ b/apps/docs/internals/generate-reference-markdown.ts @@ -116,6 +116,13 @@ const REFERENCES: Ref[] = [ mdxDir: path.join(MDX_ROOT, 'server'), contentDir: path.join(process.cwd(), 'content/reference/server/v1'), }, + { + kind: 'sdk-new', + title: 'Supabase Middleware SDK Reference', + outFile: 'middleware.md', + mdxDir: path.join(MDX_ROOT, 'middleware'), + contentDir: path.join(process.cwd(), 'content/reference/middleware/v1'), + }, { kind: 'sdk-legacy', title: 'Kotlin Client Library Reference', diff --git a/apps/docs/layouts/MainSkeleton.tsx b/apps/docs/layouts/MainSkeleton.tsx index 0799e49106f98..baedba068c641 100644 --- a/apps/docs/layouts/MainSkeleton.tsx +++ b/apps/docs/layouts/MainSkeleton.tsx @@ -109,6 +109,10 @@ const levelsData = { icon: 'reference-javascript', name: 'Server Reference v1.0', }, + reference_middleware_v1: { + icon: 'reference-javascript', + name: 'Middleware Reference v1.0', + }, reference_javascript_v1: { icon: 'reference-javascript', name: 'JavaScript Reference v1.0', diff --git a/apps/docs/package.json b/apps/docs/package.json index a394f747f7573..f0f734c7c57c6 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -16,7 +16,7 @@ "clean": "rimraf .next .turbo features/docs/generated examples __generated__ tsconfig.tsbuildinfo", "codegen:examples": "shx cp -r ../../examples ./examples", "codegen:graphql": "tsx --conditions=react-server ./scripts/graphqlSchema.ts && graphql-codegen --config codegen.ts", - "codegen:references:new:ensure": "(test -f spec/reference/javascript/v2/supabase.json || (cd spec && make download.tsdoc.v2)) && (test -f spec/reference/server/v1/server.json || (cd spec && make download.server.v1))", + "codegen:references:new:ensure": "(test -f spec/reference/javascript/v2/supabase.json || (cd spec && make download.tsdoc.v2)) && (test -f spec/reference/server/v1/server.json || (cd spec && make download.server.v1)) && (test -f spec/reference/middleware/v1/middleware.json || (cd spec && make download.middleware.v1))", "codegen:references:dart": "tsx scripts/generate-dart-reference.ts", "precodegen:references:new": "pnpm run codegen:references:new:ensure && pnpm run codegen:references:dart", "codegen:references:new": "pnpm run codegen:references:new:ensure && tsx scripts/build-reference-content.ts", diff --git a/apps/docs/scripts/search/sources/index.ts b/apps/docs/scripts/search/sources/index.ts index d3becfcb67c36..091cb8adca176 100644 --- a/apps/docs/scripts/search/sources/index.ts +++ b/apps/docs/scripts/search/sources/index.ts @@ -63,6 +63,18 @@ export async function fetchServerLibReferenceSource() { }) } +export async function fetchMiddlewareLibReferenceSource() { + // Middleware SDK is driven by the new reference pipeline. Ingest search + // sources from the generated `content/reference/middleware/v1/` outputs so + // embeddings never drift from what the renderer shows. + return loadClientLibReferenceFromNewPipeline({ + source: 'middleware-lib', + path: '/reference/middleware', + meta: { title: 'Middleware Reference', language: 'TypeScript' }, + contentDir: 'content/reference/middleware/v1', + }) +} + export async function fetchDartLibReferenceSource() { // Dart v2 is driven by the new reference pipeline. Ingest search sources from // the generated `content/reference/dart/v2/` outputs so embeddings never @@ -145,6 +157,7 @@ export async function fetchAllSources(fullIndex: boolean) { const openApiReferenceSource = fetchOpenApiReferenceSource() const jsLibReferenceSource = fetchJsLibReferenceSource() const serverLibReferenceSource = fullIndex ? fetchServerLibReferenceSource() : [] + const middlewareLibReferenceSource = fullIndex ? fetchMiddlewareLibReferenceSource() : [] const dartLibReferenceSource = fullIndex ? fetchDartLibReferenceSource() : [] const pythonLibReferenceSource = fullIndex ? fetchPythonLibReferenceSource() : [] const cSharpLibReferenceSource = fullIndex ? fetchCSharpLibReferenceSource() : [] @@ -179,6 +192,7 @@ export async function fetchAllSources(fullIndex: boolean) { openApiReferenceSource, jsLibReferenceSource, serverLibReferenceSource, + middlewareLibReferenceSource, dartLibReferenceSource, pythonLibReferenceSource, cSharpLibReferenceSource, diff --git a/apps/docs/spec/Makefile b/apps/docs/spec/Makefile index 2110c1d0b4b0e..7a4d35bb0705f 100644 --- a/apps/docs/spec/Makefile +++ b/apps/docs/spec/Makefile @@ -1,7 +1,7 @@ REPO_DIR=$(shell pwd) GENERATOR_DIR=../../../packages/generator -.PHONY: run download download.api.v1 download.mcp-tools-permissions download.storage.v1 download.tsdoc.v2 download.server.v1 transform dereference.api.v1 dereference.auth.v1 dereference.storage.v0 generate generate.sections.api.v1 generate.partials.access-control format +.PHONY: run download download.api.v1 download.mcp-tools-permissions download.storage.v1 download.tsdoc.v2 download.server.v1 download.middleware.v1 transform dereference.api.v1 dereference.auth.v1 dereference.storage.v0 generate generate.sections.api.v1 generate.partials.access-control format run: download transform generate format @@ -11,7 +11,7 @@ run: download transform generate format ############################################################################### # comment out download.auth.v1 temporarily, we're manually creating the file # download: download.api.v1 download.auth.v1 download.storage.v1 download.tsdoc.v2 -download: download.api.v1 download.mcp-tools-permissions download.storage.v1 download.tsdoc.v2 download.server.v1 +download: download.api.v1 download.mcp-tools-permissions download.storage.v1 download.tsdoc.v2 download.server.v1 download.middleware.v1 download.api.v1: curl -sS https://api.supabase.com/api/v1-json > $(REPO_DIR)/api_v1_openapi.json @@ -54,6 +54,9 @@ download.tsdoc.v2: download.server.v1: curl -sSf https://supabase.github.io/server/spec.json > $(REPO_DIR)/reference/server/v1/server.json +download.middleware.v1: + curl -sSf https://supabase.github.io/middleware/spec.json > $(REPO_DIR)/reference/middleware/v1/middleware.json + download.analytics.v0: curl -sS https://logflare.app/api/openapi > $(REPO_DIR)/analytics_v0_openapi.json diff --git a/apps/docs/spec/reference/middleware/v1/config.json b/apps/docs/spec/reference/middleware/v1/config.json new file mode 100644 index 0000000000000..74fca13b72c83 --- /dev/null +++ b/apps/docs/spec/reference/middleware/v1/config.json @@ -0,0 +1,5 @@ +{ + "categoryOrder": ["Composition", "Middleware", "Environment", "Types"], + "partialsOrder": ["introduction", "installing"], + "navigationPrefixes": {} +} diff --git a/apps/docs/spec/reference/middleware/v1/partials/installing.mdx b/apps/docs/spec/reference/middleware/v1/partials/installing.mdx new file mode 100644 index 0000000000000..dd4da3ab121dd --- /dev/null +++ b/apps/docs/spec/reference/middleware/v1/partials/installing.mdx @@ -0,0 +1,84 @@ +--- +id: installing +title: Installing +slug: installing +--- + +### Install as a package + + + + + Install `@supabase/middleware` via your package manager. + + + + + + + + + ```sh Terminal + npm install @supabase/middleware + ``` + + + + + ```sh Terminal + yarn add @supabase/middleware + ``` + + + + + ```sh Terminal + pnpm add @supabase/middleware + ``` + + + + + + + +### Use via JSR (Deno / Bun) + + + + + `@supabase/middleware` is also published to [JSR](https://jsr.io/@supabase/middleware) for Deno and Bun environments. + + + + + + + + + ```sh Terminal + deno add jsr:@supabase/middleware + ``` + + + + + ```sh Terminal + bunx jsr add @supabase/middleware + ``` + + + + + + diff --git a/apps/docs/spec/reference/middleware/v1/partials/introduction.mdx b/apps/docs/spec/reference/middleware/v1/partials/introduction.mdx new file mode 100644 index 0000000000000..a73f26d31622d --- /dev/null +++ b/apps/docs/spec/reference/middleware/v1/partials/introduction.mdx @@ -0,0 +1,26 @@ +--- +id: introduction +title: Introduction +--- + +`@supabase/middleware` is a framework-agnostic engine for composing server-side request middleware. You write small units with `defineMiddleware`, compose them with `pipeline`, and run the result as a standard Fetch handler on Node, Deno, Bun, and Cloudflare Workers. + +Each middleware can guard the request, edit the response, or contribute typed values to a shared context that later middleware and your handler read. TypeScript checks the composition: a middleware that needs a value from an earlier middleware will not compile unless that middleware runs first. + + + +`@supabase/middleware` is in alpha. APIs may change between 0.x releases. The `middleware` option on `withSupabase` in `@supabase/server` is also alpha. + + + +This package contains the engine and the framework-neutral middleware: `withCors` and `withFeatureFlag`. Supabase-specific middleware such as `withClaims`, `withSupabaseClient`, `withSupabaseAdminClient`, `withPostgresClient`, and `withPostgresAdminClient` ships in [`@supabase/server`](/docs/reference/server/introduction). + +### Trust model + +A middleware in your pipeline runs with the same access as your own handler code. It sees the full request, everything earlier middleware put on the context, and every response on the way out. Nothing sandboxes it. Only compose middleware you trust as much as your own code. + +Ordering is your permission boundary. A middleware can only read context values that earlier entries contributed, so place third-party middleware as early as possible and sensitive contributions as late as possible. + +### Environment access + +Middleware reads configuration through `getEnv` instead of `process.env`. On Node, Deno, and Bun, `getEnv` reads the process environment. Cloudflare Workers pass env bindings per request instead of exposing a global environment; the pipeline seeds each request's bindings, and `getEnv` reads the ones for the current request. From ffe10b8cf37d34e8c5b7ee07ddeb26159cc71864 Mon Sep 17 00:00:00 2001 From: Tomohiro Mitani Date: Wed, 2 Sep 2026 20:27:20 +0900 Subject: [PATCH 05/14] Add Tomohiro Mitani to humans.txt (#49882) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES/NO ## What kind of change does this PR introduce? Bug fix, feature, docs update, ... ## What is the current behavior? Please link any relevant issues here. ## What is the new behavior? Feel free to include screenshots if it includes visual changes. ## Additional context Add any other context or screenshots. ## Summary by CodeRabbit * **Documentation** * Added Tomohiro Mitani to the project’s team listing. --- 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 712db4ab82876..71fd9385cafeb 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -313,6 +313,7 @@ Tom Ashley Tom G Tomás Pozo Tomás Torgal +Tomohiro Mitani Tyler Hillery Tyler Fontaine Tyler Shukert From 47fbf26e4b8de8863c05b4deed52b5cde0b5928d Mon Sep 17 00:00:00 2001 From: Monica Khoury <99693443+monicakh@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:32:08 +0300 Subject: [PATCH 06/14] fix: sync Bucket state when bucket public/private setting changes (#49891) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What is the current behavior? Fixes FE-4056. In the Storage Explorer, if you edit a bucket's access level (public/private) via "Edit bucket", the "Get URL" action keeps generating the old signed/public URL type. Clicking the in-explorer refresh button doesn't fix it either, since it only re-lists objects and never refetches bucket metadata. Only a full browser reload resolves it. ### What is the new behavior? selectedBucket in the Storage Explorer's Valtio store is now kept in sync with the bucket query on every change, not just on project switch. "Get URL" now always reads the current public/private state, so it correctly returns a public URL or a signed URL immediately after the bucket's access level is changed - no reload required. ### Additional context Added a second effect to sync selectedBucket whenever the bucket query value changes. ## Summary by CodeRabbit * **Bug Fixes** * Updated bucket selection state when bucket settings change, preventing stale bucket information in actions such as “Get URL.” --- apps/studio/state/storage-explorer.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/studio/state/storage-explorer.tsx b/apps/studio/state/storage-explorer.tsx index d4fd8a846910e..a2c7b08930896 100644 --- a/apps/studio/state/storage-explorer.tsx +++ b/apps/studio/state/storage-explorer.tsx @@ -1899,6 +1899,16 @@ export const StorageExplorerStateContextProvider = ({ children }: PropsWithChild bucket, ]) + // [Monica] The effect above only refreshes `selectedBucket` when the project changes, so + // editing the current bucket (e.g. toggling public/private) doesn't update it there. This + // keeps `selectedBucket` synced to the bucket query on every change, so Get URL always + // uses the current public/private state instead of a stale one from initial load. + useEffect(() => { + if (bucket && state.projectRef === project?.ref) { + state.selectedBucket = bucket + } + }, [bucket, project?.ref, state.projectRef]) + return ( {children} From abb7f3ede25600b1f0ce3a5d2a2982d5456043fe Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:42:57 +0200 Subject: [PATCH 07/14] fix(workers): refresh Workers view FE-4323 (#49887) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The Workers view can remain stale after a worker is deployed through the CLI, because the dashboard has no deployment mutation to invalidate its list query. ## Fix Add a manual Refresh action to the Workers header and force the Workers list query to refetch whenever the browser regains focus. ## How to test - Open a project’s Workers view and select Refresh. - Expected result: the list requests current worker data and renders it. - Deploy a worker through the CLI, then return focus to the Workers view. - Expected result: the Workers list refreshes even when its cached data is fresh. Closes FE-4323. ## Summary by CodeRabbit - **New Features** - Added Refresh buttons to the Workers page and worker list. - Refreshing displays the latest worker information and shows a loading state while data is retrieved. - Worker data now automatically refreshes when the browser window regains focus. - Added a Refresh action to unexpected-error messages, allowing failed requests to be retried without leaving the page. - **Bug Fixes** - Improved recovery from failed worker data requests through in-page retry support. --- .../interfaces/Workers/WorkersList.test.tsx | 23 ++++++++++--- .../interfaces/Workers/WorkersList.tsx | 18 +++++++--- apps/studio/data/workers/workers-query.ts | 1 + .../pages/project/[ref]/workers/index.tsx | 23 ++++++++++++- .../project/[ref]/workers/index.test.tsx | 34 ++++++++++++++++++- 5 files changed, 88 insertions(+), 11 deletions(-) diff --git a/apps/studio/components/interfaces/Workers/WorkersList.test.tsx b/apps/studio/components/interfaces/Workers/WorkersList.test.tsx index e78acd3b3026e..09bd808ad2b92 100644 --- a/apps/studio/components/interfaces/Workers/WorkersList.test.tsx +++ b/apps/studio/components/interfaces/Workers/WorkersList.test.tsx @@ -18,8 +18,16 @@ const worker = (name: string, overrides: Partial = {}): Worker => ({ ...overrides, }) -const renderList = (workers: Worker[]) => - customRender() +const renderList = (workers: Worker[], onRefresh = vi.fn()) => + customRender( + + ) const rowNames = () => screen @@ -48,12 +56,19 @@ describe('WorkersList', () => { await userEvent.type(screen.getByPlaceholderText('Search by name'), 'resize') expect(rowNames()).toEqual(['resize-images']) - expect(screen.getByText('1 worker')).toBeVisible() - await userEvent.type(screen.getByPlaceholderText('Search by name'), '-nope') expect(screen.getByText('No workers match your filters')).toBeVisible() }) + it('refreshes the workers list on request', async () => { + const onRefresh = vi.fn() + renderList([worker('embed')], onRefresh) + + await userEvent.click(screen.getByRole('button', { name: 'Refresh' })) + + expect(onRefresh).toHaveBeenCalledOnce() + }) + it('pages through the workers ten at a time', async () => { const workers = Array.from({ length: 12 }, (_, index) => worker(`worker-${index}`)) renderList(workers) diff --git a/apps/studio/components/interfaces/Workers/WorkersList.tsx b/apps/studio/components/interfaces/Workers/WorkersList.tsx index 7503d8c1533e2..ca2aa4b9d46b7 100644 --- a/apps/studio/components/interfaces/Workers/WorkersList.tsx +++ b/apps/studio/components/interfaces/Workers/WorkersList.tsx @@ -1,4 +1,4 @@ -import { ChevronLeft, ChevronRight, Terminal } from 'lucide-react' +import { ChevronLeft, ChevronRight, RefreshCw, Terminal } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { useState } from 'react' @@ -32,6 +32,8 @@ interface WorkersListProps { projectRef: string workers: Worker[] onDeploy: () => void + onRefresh: () => void + isRefreshing: boolean } const STATE_FILTERS: { value: WorkerBuildState | 'all'; label: string }[] = [ @@ -55,7 +57,13 @@ const parseStateFilter = (value: string): WorkerBuildState | 'all' => const parseAccessFilter = (value: string): WorkerAccess | 'all' => ACCESS_FILTERS.find((option) => option.value === value)?.value ?? 'all' -export const WorkersList = ({ projectRef, workers, onDeploy }: WorkersListProps) => { +export const WorkersList = ({ + projectRef, + workers, + onDeploy, + onRefresh, + isRefreshing, +}: WorkersListProps) => { const router = useRouter() const [search, setSearch] = useState('') const [stateFilter, setStateFilter] = useState('all') @@ -124,9 +132,9 @@ export const WorkersList = ({ projectRef, workers, onDeploy }: WorkersListProps)
- - {filtered.length} worker{filtered.length === 1 ? '' : 's'} - + diff --git a/apps/studio/data/workers/workers-query.ts b/apps/studio/data/workers/workers-query.ts index 5aaed1734df58..a74453ffa6335 100644 --- a/apps/studio/data/workers/workers-query.ts +++ b/apps/studio/data/workers/workers-query.ts @@ -28,4 +28,5 @@ export const workersQueryOptions = ({ projectRef }: WorkersVariables) => queryKey: workersKeys.list(projectRef), queryFn: ({ signal }) => getWorkers({ projectRef }, signal), enabled: IS_PLATFORM && typeof projectRef !== 'undefined', + refetchOnWindowFocus: 'always', }) diff --git a/apps/studio/pages/project/[ref]/workers/index.tsx b/apps/studio/pages/project/[ref]/workers/index.tsx index 73debf88890ce..327dfd8e7320f 100644 --- a/apps/studio/pages/project/[ref]/workers/index.tsx +++ b/apps/studio/pages/project/[ref]/workers/index.tsx @@ -1,6 +1,8 @@ import { useQuery } from '@tanstack/react-query' import { useParams } from 'common' +import { RefreshCw } from 'lucide-react' import { useState } from 'react' +import { Button } from 'ui' import { Admonition } from 'ui-patterns/Admonition' import { PageContainer } from 'ui-patterns/PageContainer' import { @@ -38,6 +40,8 @@ const WorkersPage: NextPageWithLayout = () => { isPending, isError, isSuccess, + isFetching, + refetch, } = useQuery(workersQueryOptions({ projectRef: ref })) const isNotEnrolled = isError && isWorkersUnavailable(error) @@ -74,7 +78,22 @@ const WorkersPage: NextPageWithLayout = () => { /> )} {isMissingPermission && } - {isUnexpectedError && } + {isUnexpectedError && ( + } + loading={isFetching} + onClick={() => refetch()} + > + Refresh + + } + /> + )} {isSuccess && workers.length === 0 && ( setIsDeployInstructionsOpen(true)} /> )} @@ -83,6 +102,8 @@ const WorkersPage: NextPageWithLayout = () => { projectRef={ref} workers={workers} onDeploy={() => setIsDeployInstructionsOpen(true)} + onRefresh={() => refetch()} + isRefreshing={isFetching} /> )} diff --git a/apps/studio/tests/pages/project/[ref]/workers/index.test.tsx b/apps/studio/tests/pages/project/[ref]/workers/index.test.tsx index a30ff8ba80bd2..72111ae36b9ea 100644 --- a/apps/studio/tests/pages/project/[ref]/workers/index.test.tsx +++ b/apps/studio/tests/pages/project/[ref]/workers/index.test.tsx @@ -1,5 +1,5 @@ import { QueryClient } from '@tanstack/react-query' -import { screen } from '@testing-library/react' +import { fireEvent, screen } from '@testing-library/react' import type { components } from 'api-types' import { HttpResponse } from 'msw' import { beforeEach, describe, expect, it } from 'vitest' @@ -90,6 +90,17 @@ describe('/project/[ref]/workers', () => { expect(screen.queryByRole('table')).not.toBeInTheDocument() }) + it('refreshes the workers list on request', async () => { + mockWorkersList([workerDatum('existing')]) + + await renderWorkersPage() + + mockWorkersList([workerDatum('embed')]) + fireEvent.click(screen.getByRole('button', { name: 'Refresh' })) + + expect(await screen.findByRole('link', { name: 'embed' })).toBeVisible() + }) + it('explains that a project outside the alpha is not enrolled', async () => { mockWorkersListFailure(404) @@ -109,4 +120,25 @@ describe('/project/[ref]/workers', () => { ).toBeVisible() expect(screen.queryByRole('table')).not.toBeInTheDocument() }) + + it('allows retrying an unexpected error', async () => { + let requestCount = 0 + addAPIMock({ + method: 'get', + path: '/v2/projects/:ref/workers', + response: (): HttpResponse | HttpResponse => { + if (requestCount++ === 0) { + return HttpResponse.json({ message: 'Unavailable' }, { status: 500 }) + } + + return HttpResponse.json({ data: [workerDatum('embed')] }) + }, + }) + + await renderWorkersPage() + + fireEvent.click(screen.getByRole('button', { name: 'Refresh' })) + + expect(await screen.findByRole('link', { name: 'embed' })).toBeVisible() + }) }) From 343dee6bac42e1f6b2a0a2ab12d9e98adc440050 Mon Sep 17 00:00:00 2001 From: Jeremias Menichelli Date: Wed, 2 Sep 2026 15:01:34 +0200 Subject: [PATCH 08/14] chore: Add rewrites to kb vercel config (#49900) --- apps/kb/vercel.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/kb/vercel.json b/apps/kb/vercel.json index 44d765d2307ee..2c4b12e56536d 100644 --- a/apps/kb/vercel.json +++ b/apps/kb/vercel.json @@ -1,4 +1,8 @@ { "buildCommand": "pnpm build", - "redirects": [{ "source": "/", "destination": "/kb", "permanent": false }] + "redirects": [{ "source": "/", "destination": "/kb", "permanent": false }], + "rewrites": [ + { "source": "/kb", "destination": "/index.html" }, + { "source": "/kb/:path*", "destination": "/:path*" } + ] } From 9aaa75330673f0581ef9af8526b303c06ff8b75b Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:07:06 -0400 Subject: [PATCH 09/14] feat(studio): add telemetry for explorer/sql editor temporary switch buttons (#49898) ## Summary Add PostHog event tracking for the two new buttons introduced in PR supabase/supabase#49698 that allow users to temporarily switch between the Explorer and SQL Editor: * **Explorer button**: "Back to SQL Editor" button in the Explorer sidebar title bar now fires `explorer_temp_access_sql_editor_clicked` event * **SQL Editor button**: "Back to Explorer" button in the SQL Editor title bar (shown during temporary visits) now fires `sql_editor_back_explorer_clicked` event Both event interfaces follow the repo's telemetry-standards conventions, carrying only `groups: TelemetryGroups` property with no additional custom properties. ## Test plan - [X] Verify `explorer_temp_access_sql_editor_clicked` event fires in PostHog when clicking "Back to SQL Editor" button in Explorer - [X] Verify `sql_editor_back_explorer_clicked` event fires in PostHog when clicking "Back to Explorer" button in SQL Editor - [X] Run typecheck: `pnpm typecheck` passes without errors - [X] Run lint: `pnpm lint --filter=studio` passes ## Issue Resolves [FE-4213](https://linear.app/supabase/issue/FE-4213/explorer-set-up-telemetry-for-metrics-where-appropriate) ## Summary by CodeRabbit * **Analytics** * Added tracking for navigation from the Explorer to the SQL Editor. * Added tracking for returning from the SQL Editor to the Explorer. --- .../layouts/ExplorerLayout/ExplorerLayout.tsx | 7 ++++- .../layouts/editors/EditorBaseLayout.tsx | 3 ++ packages/common/telemetry-constants.ts | 28 +++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx index 2011c7b5ff99a..5414ed312a692 100644 --- a/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx +++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx @@ -27,6 +27,7 @@ import { useCreateQuery, } from '@/components/interfaces/Explorer/hooks' import { useIsTemporarySqlEditorVisit } from '@/hooks/misc/useIsTemporarySqlEditorVisit' +import { useTrack } from '@/lib/telemetry/track' import { editorEntityTypes, EXPLORER_HOME_TAB, @@ -102,6 +103,7 @@ export const ExplorerLayout = ({ browserTitle, children, title }: ExplorerLayout const BackToSqlEditorButton = () => { const { ref } = useParams() + const track = useTrack() const { setIsTemporary } = useIsTemporarySqlEditorVisit(ref) if (!ref) return null @@ -114,7 +116,10 @@ const BackToSqlEditorButton = () => { setIsTemporary(true)} + onClick={() => { + setIsTemporary(true) + track('explorer_temp_access_sql_editor_clicked') + }} /> ) diff --git a/apps/studio/components/layouts/editors/EditorBaseLayout.tsx b/apps/studio/components/layouts/editors/EditorBaseLayout.tsx index 78e3bfc29960c..7f61a8a5b9fe6 100644 --- a/apps/studio/components/layouts/editors/EditorBaseLayout.tsx +++ b/apps/studio/components/layouts/editors/EditorBaseLayout.tsx @@ -9,6 +9,7 @@ import { CollapseButton } from '../Tabs/CollapseButton' import { EditorTabs } from '../Tabs/Tabs' import { useEditorType } from './EditorsLayout.hooks' import { useIsTemporarySqlEditorVisit } from '@/hooks/misc/useIsTemporarySqlEditorVisit' +import { useTrack } from '@/lib/telemetry/track' import { useTabsStateSnapshot } from '@/state/tabs' export interface ExplorerLayoutProps extends ComponentProps { @@ -86,6 +87,7 @@ export const EditorBaseLayout = ({ const BackToExplorerButton = () => { const { ref } = useParams() const router = useRouter() + const track = useTrack() const { isTemporary, setIsTemporary } = useIsTemporarySqlEditorVisit(ref) if (!ref || !isTemporary) return null @@ -95,6 +97,7 @@ const BackToExplorerButton = () => { tooltip="Back to Explorer" onClick={() => { setIsTemporary(false) + track('sql_editor_back_explorer_clicked') router.push(`/project/${ref}/explorer`) }} /> diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 0e4365ed54eac..dbe916ee052e7 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -1587,6 +1587,32 @@ export interface ExplorerBannerCtaButtonClickedEvent { groups: TelemetryGroups } +/** + * User clicked the button in the Explorer sidebar title bar to temporarily switch to the SQL + * Editor for snippet access. + * + * @group Events + * @source studio + * @page /project/{ref}/explorer + */ +export interface ExplorerTempAccessSqlEditorClickedEvent { + action: 'explorer_temp_access_sql_editor_clicked' + groups: TelemetryGroups +} + +/** + * User clicked the "Back to Explorer" button in the SQL Editor title bar, shown only when the + * visit originated from the Explorer's temporary switch button. + * + * @group Events + * @source studio + * @page /project/{ref}/sql + */ +export interface SqlEditorBackExplorerClickedEvent { + action: 'sql_editor_back_explorer_clicked' + groups: TelemetryGroups +} + /** * User clicked a metric card PID in the Overview panel of the Database Connections observability page, selecting it in the activity table below. * @@ -3891,6 +3917,8 @@ export type TelemetryEvent = | ExplorerBannerExposedEvent | ExplorerBannerDismissButtonClickedEvent | ExplorerBannerCtaButtonClickedEvent + | ExplorerTempAccessSqlEditorClickedEvent + | SqlEditorBackExplorerClickedEvent | SessionTerminateButtonClickedEvent | SessionTerminateSubmittedEvent | QueryCancelButtonClickedEvent From c6435f1cbeefa8f69a70f7a5a14373529bcff09a Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Wed, 2 Sep 2026 21:25:16 +0800 Subject: [PATCH 10/14] fix(studio): hide shared pooler chart for high availability projects (#49904) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit High Availability projects run Multigres and don't have Supavisor, so the Shared Pooler (Supavisor) client connections chart in the database report only ever rendered an "Unable to load data" error for them. This hides the chart for HA projects, following the same pattern as the Disk IO Burst Balance chart. **Changed:** - `supavisor-connections-active` chart is now hidden when `project.high_availability` is true **Added:** - Unit tests covering the shared pooler chart's visibility for standard, HA, and unentitled projects ## To test - Open Reports → Database on a High Availability project – the Shared Pooler (Supavisor) client connections chart should no longer appear - Open the same report on a standard Pro project – the chart should still render as before ## Summary by CodeRabbit * **Bug Fixes** * The active connection chart is now hidden for High Availability projects and projects without the database entitlement, preventing empty or unavailable data from being displayed. * **Tests** * Added coverage to verify the chart appears only for eligible standard projects. Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> --- .../data/reports/database-charts.test.ts | 23 +++++++++++++++++++ apps/studio/data/reports/database-charts.ts | 3 ++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/studio/data/reports/database-charts.test.ts b/apps/studio/data/reports/database-charts.test.ts index 1fdf6e2c06b5b..8d41b0365877d 100644 --- a/apps/studio/data/reports/database-charts.test.ts +++ b/apps/studio/data/reports/database-charts.test.ts @@ -77,6 +77,29 @@ describe('getReportAttributesV2 dedicated pooler chart', () => { }) }) +const getSupavisorChart = (project: Project) => + getReportAttributesV2(ENTITLED_FEATURES, project).find( + (chart) => chart.id === 'supavisor-connections-active' + ) + +describe('getReportAttributesV2 shared pooler chart', () => { + it('shows the chart for standard projects', () => { + expect(getSupavisorChart(buildProject())?.hide).toBe(false) + }) + + it('hides the chart for high availability projects', () => { + expect(getSupavisorChart(buildProject({ high_availability: true }))?.hide).toBe(true) + }) + + it('hides the chart when the database entitlement is missing', () => { + expect( + getReportAttributesV2([], buildProject()).find( + (chart) => chart.id === 'supavisor-connections-active' + )?.hide + ).toBe(true) + }) +}) + describe('getReportAttributesV2 disk-io-burst-balance chart', () => { it('shows the chart for burstable non high availability projects', () => { expect(getBurstBalanceChart(buildProject())?.hide).toBe(false) diff --git a/apps/studio/data/reports/database-charts.ts b/apps/studio/data/reports/database-charts.ts index 0615d1f7052cd..65e300266e016 100644 --- a/apps/studio/data/reports/database-charts.ts +++ b/apps/studio/data/reports/database-charts.ts @@ -541,7 +541,8 @@ export const getReportAttributesV2: ( valuePrecision: 0, entitlement: 'database', requiredPlan: 'Pro', - hide: !entitledFeatures.includes('database'), + // High Availability projects don't run Supavisor, so there's no data to show. + hide: !entitledFeatures.includes('database') || isHighAvailability, showTooltip: true, showLegend: false, showMaxValue: false, From e7d91dbd064c3641ba3b163c191d107ac868e521 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:30:42 -0400 Subject: [PATCH 11/14] fix(studio): display diff for view-only notebook cell edits (#49901) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixed a bug in the AI Assistant notebook-update proposal preview where a `replace_cell` operation that only changed a cell's view (table ↔ chart) or chart parameters (type, x/y columns, cumulative, scale, labels) would show as a "Replaced" row but the expanded diff would appear empty. **Root cause:** The diff editor only compared the cell's SQL text; view and chart configuration were never considered, so changes to those aspects showed no diff. **Solution:** * Refactored `getCellMetadata` to return structured `NotebookCellFields` with separate `source` (database/time range) and `view` (table/chart) fields instead of a single concatenated string * Added `formatChartConfig` and `formatCellView` helpers to describe chart cells * Updated `getEntryMetadata` to diff source and view independently, showing only the fields that actually changed (e.g., "Table → Chart (bar, ...)" when only the view changed, with the unchanged database omitted) * If neither field changed, metadata is hidden entirely ## Test plan * Added test cases for: chart-view cells reporting a `view` field, view-only changes surfacing without the unchanged database, chart-parameter-only changes surfacing without the unchanged database, database-only changes surfacing without the unchanged view, and fully-unchanged replacements hiding metadata entirely * All 43 tests in the touched test file pass * `tsc --noEmit` on apps/studio shows no new type errors ## Summary by CodeRabbit * **Enhancements** * Improved AI Assistant notebook previews with clearer cell details, including source content and table or chart views. * Chart previews now show key configuration details, such as chart type and selected dimensions * Replacement previews highlight only the fields that changed and hide entries with no visible changes. ## Summary by CodeRabbit * **New Features** * Notebook previews now distinguish cell content from its view, including table and chart details. * Chart previews display relevant configuration, such as chart type and axes. * Log previews include their formatted time range. * Replacement previews now show only the fields that changed. * **Bug Fixes** * Unchanged replacements are now hidden instead of displaying misleading content. --- .../AssistantNotebookPreview.utils.test.ts | 110 ++++++++++++++++-- .../AssistantNotebookPreview.utils.ts | 83 +++++++++---- 2 files changed, 164 insertions(+), 29 deletions(-) diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.test.ts b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.test.ts index fe13a029f6fdd..4562af634fc49 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.test.ts +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.test.ts @@ -48,6 +48,38 @@ const agentDatabaseCell = (database_identifier?: string): AgentCell => ({ database_identifier, }) +const chartDatabaseCell = (id: string, ySeries: string[]): CellWire => ({ + _tag: 'database_cell', + _id: id, + sql: 'select 1', + row_limit: 100, + view: 'chart', + chart: { + type: 'bar', + x_column: 'day', + y_series: ySeries, + cumulative: false, + scale: 'linear', + show_labels: false, + }, +}) + +const agentChartDatabaseCell = (ySeries: string[], database_identifier?: string): AgentCell => ({ + _tag: 'database_cell', + sql: 'select 1', + row_limit: 100, + database_identifier, + view: 'chart', + chart: { + type: 'bar', + x_column: 'day', + y_series: ySeries, + cumulative: false, + scale: 'linear', + show_labels: false, + }, +}) + const successfulDatabaseContext = ( databases: Array<{ identifier: string; region: string }> = [] ): NotebookDatabaseContext => ({ @@ -217,10 +249,11 @@ describe('getCellMetadata', () => { }) }) - it('labels an implicit primary database', () => { + it('labels an implicit primary database and table view', () => { expect(getCellMetadata(wireDatabaseCell('cell-1'), successfulDatabaseContext())).toEqual({ status: 'ready', - text: 'Database: Primary', + source: 'Database: Primary', + view: 'Table', }) }) @@ -232,7 +265,8 @@ describe('getCellMetadata', () => { ) ).toEqual({ status: 'ready', - text: 'Database: Replica', + source: 'Database: Replica', + view: 'Table', }) }) @@ -251,13 +285,24 @@ describe('getCellMetadata', () => { wireDatabaseCell('cell-1', 'Signups', 'missing-database'), successfulDatabaseContext() ) - ).toEqual({ status: 'ready', text: 'Database: Unknown' }) + ).toEqual({ status: 'ready', source: 'Database: Unknown', view: 'Table' }) }) it('labels a log cell with its formatted time range', () => { expect(getCellMetadata(wireLogCell('cell-1'), successfulDatabaseContext())).toEqual({ status: 'ready', - text: 'Time range: Last 7 days', + source: 'Time range: Last 7 days', + view: 'Table', + }) + }) + + it('reports the chart summary as the view field for a chart-view database cell', () => { + expect( + getCellMetadata(chartDatabaseCell('cell-1', ['signups']), successfulDatabaseContext()) + ).toEqual({ + status: 'ready', + source: 'Database: Primary', + view: 'Chart (bar, x: day, y: signups)', }) }) }) @@ -309,7 +354,7 @@ describe('getEntryMetadata', () => { ).toEqual({ status: 'loading' }) }) - it('returns a single line when replacement metadata is unchanged', () => { + it('hides metadata entirely when a replacement changes neither the database nor the view', () => { expect( getEntryMetadata( { @@ -320,7 +365,58 @@ describe('getEntryMetadata', () => { }, successfulDatabaseContext() ) - ).toEqual({ status: 'ready', text: 'Database: Primary' }) + ).toEqual({ status: 'hidden' }) + }) + + it('surfaces only the view change from table to chart, omitting the unchanged database', () => { + expect( + getEntryMetadata( + { + _tag: 'replaced', + before: wireDatabaseCell('cell-1'), + after: agentChartDatabaseCell(['signups']), + operationIndex: 0, + }, + successfulDatabaseContext() + ) + ).toEqual({ + status: 'ready', + text: 'Table → Chart (bar, x: day, y: signups)', + }) + }) + + it('surfaces only a chart parameter change, omitting the unchanged database', () => { + expect( + getEntryMetadata( + { + _tag: 'replaced', + before: chartDatabaseCell('cell-1', ['signups']), + after: agentChartDatabaseCell(['active_users']), + operationIndex: 0, + }, + successfulDatabaseContext() + ) + ).toEqual({ + status: 'ready', + text: 'Chart (bar, x: day, y: signups) → Chart (bar, x: day, y: active_users)', + }) + }) + + it('surfaces only a database change, omitting the unchanged view', () => { + expect( + getEntryMetadata( + { + _tag: 'replaced', + before: chartDatabaseCell('cell-1', ['signups']), + after: agentChartDatabaseCell(['signups'], 'replica-3'), + operationIndex: 0, + }, + successfulDatabaseContext([{ identifier: 'replica-3', region: 'us-east-1' }]) + ) + ).toEqual({ + status: 'ready', + text: 'Database: Primary → Database: Replica', + }) }) }) diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.ts b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.ts index a8717269f6a5d..bedbe9ae1e89c 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.ts +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.ts @@ -5,7 +5,7 @@ import type { NotebookCellDiffEntry, OperationResultCell, } from '@/data/content/notebooks/notebook-operations' -import type { TimeRange } from '@/data/content/notebooks/notebook-schema' +import type { ChartConfig, TimeRange } from '@/data/content/notebooks/notebook-schema' import type { Database } from '@/data/read-replicas/replicas-query' type DatabaseDetails = Pick @@ -23,6 +23,11 @@ export type NotebookDatabaseTarget = | { status: 'unknown' } | { status: 'error' } +export type NotebookCellFields = + | { status: 'hidden' } + | { status: 'loading' } + | { status: 'ready'; source?: string; view?: string } + export type NotebookCellMetadata = | { status: 'hidden' } | { status: 'loading' } @@ -139,49 +144,83 @@ function formatDatabaseTarget(target: Exclude +): string { + if ((cell.view ?? 'table') !== 'chart') return 'Table' + return `Chart (${cell.chart !== undefined ? formatChartConfig(cell.chart) : 'unconfigured'})` +} + export function getCellMetadata( cell: OperationResultCell, databaseContext: NotebookDatabaseContext -): NotebookCellMetadata { +): NotebookCellFields { switch (cell._tag) { case 'markdown_cell': return { status: 'hidden' } case 'database_cell': { const target = resolveNotebookDatabaseTarget(cell.database_identifier, databaseContext) - return target.status === 'loading' - ? { status: 'loading' } - : { status: 'ready', text: formatDatabaseTarget(target) } + if (target.status === 'loading') return { status: 'loading' } + + return { status: 'ready', source: formatDatabaseTarget(target), view: formatCellView(cell) } } case 'log_cell': - return { status: 'ready', text: `Time range: ${formatTimeRange(cell.time_range)}` } + return { + status: 'ready', + source: `Time range: ${formatTimeRange(cell.time_range)}`, + view: formatCellView(cell), + } } } -/** Header metadata for a diff row, including a before → after pair on replacements. */ +/** Joins a cell's fields into display text, dropping the view when it's just the default table. */ +function formatCellFieldsText(fields: { source?: string; view?: string }): string { + return [fields.source, fields.view === 'Table' ? undefined : fields.view] + .filter((part): part is string => part !== undefined) + .join(' · ') +} + +/** A field that's identical before and after carries no information about what changed, so it's dropped. */ +function diffField(before: string | undefined, after: string | undefined): string | undefined { + if (before === after) return undefined + return `${before ?? 'Not configured'} → ${after ?? 'Not configured'}` +} + +/** Header metadata for a diff row. On a replacement, only the fields that actually changed are shown. */ export function getEntryMetadata( entry: NotebookCellDiffEntry, databaseContext: NotebookDatabaseContext ): NotebookCellMetadata { if (entry._tag !== 'replaced') { - return getCellMetadata(entry.cell, databaseContext) - } + const fields = getCellMetadata(entry.cell, databaseContext) + if (fields.status !== 'ready') return fields - const beforeMetadata = getCellMetadata(entry.before, databaseContext) - const afterMetadata = getCellMetadata(entry.after, databaseContext) - if (beforeMetadata.status === 'loading' || afterMetadata.status === 'loading') { - return { status: 'loading' } + const text = formatCellFieldsText(fields) + return text === '' ? { status: 'hidden' } : { status: 'ready', text } } - const beforeText = beforeMetadata.status === 'ready' ? beforeMetadata.text : null - const afterText = afterMetadata.status === 'ready' ? afterMetadata.text : null - if (beforeText === null && afterText === null) return { status: 'hidden' } - if (beforeText === afterText) return { status: 'ready', text: afterText ?? 'No metadata' } + const before = getCellMetadata(entry.before, databaseContext) + const after = getCellMetadata(entry.after, databaseContext) + if (before.status === 'loading' || after.status === 'loading') return { status: 'loading' } - return { - status: 'ready', - text: `${beforeText ?? 'No metadata'} → ${afterText ?? 'No metadata'}`, - } + const beforeSource = before.status === 'ready' ? before.source : undefined + const afterSource = after.status === 'ready' ? after.source : undefined + const beforeView = before.status === 'ready' ? before.view : undefined + const afterView = after.status === 'ready' ? after.view : undefined + + const parts = [diffField(beforeSource, afterSource), diffField(beforeView, afterView)].filter( + (part): part is string => part !== undefined + ) + + return parts.length > 0 ? { status: 'ready', text: parts.join(' · ') } : { status: 'hidden' } } export type NotebookDiffSummary = From a46170aa76180ab6eaa4a03f033374953674ff28 Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:45:46 +0200 Subject: [PATCH 12/14] feat(workers): add log filters FE-4322 (#49893) ## Problem Workers log views were limited to the most recent 24 hours and could not be narrowed by event text or HTTP method. ## Fix Adds selectable time ranges, event-message search, and an HTTP-method filter for invocation logs. Filters are applied in the analytics query and included in the cache key. ## How to test - Open a worker and select the Invocations tab. - Change the time range, enter an event message, and select a method. - Expected result: only matching invocation logs are shown. - Open Logs or Activity. - Expected result: message and time filters are available; the method filter is hidden. ## Summary by CodeRabbit * **New Features** * Added worker log filtering by date range and event message. * Applied a default 24-hour time range to log searches. * Improved filter controls and updated empty-state messaging to reflect the selected range. * **Bug Fixes** * Improved filtering accuracy and safer handling of special characters in event messages. * **Tests** * Added coverage for date-range and message-filter query behavior. --- .../Workers/WorkerDetail/WorkerLogsTab.tsx | 59 +++++++++++++++++-- apps/studio/data/workers/keys.ts | 12 +++- .../data/workers/worker-logs-query.test.ts | 37 ++++++++++++ apps/studio/data/workers/worker-logs-query.ts | 56 +++++++++++++----- 4 files changed, 142 insertions(+), 22 deletions(-) diff --git a/apps/studio/components/interfaces/Workers/WorkerDetail/WorkerLogsTab.tsx b/apps/studio/components/interfaces/Workers/WorkerDetail/WorkerLogsTab.tsx index 1056bd7295bb5..183af913a42d2 100644 --- a/apps/studio/components/interfaces/Workers/WorkerDetail/WorkerLogsTab.tsx +++ b/apps/studio/components/interfaces/Workers/WorkerDetail/WorkerLogsTab.tsx @@ -1,12 +1,17 @@ import { useQuery } from '@tanstack/react-query' import { useParams } from 'common' -import { RefreshCw } from 'lucide-react' +import { RefreshCw, Search } from 'lucide-react' import { useState } from 'react' -import { Button } from 'ui' +import { Button, InputGroup, InputGroupAddon, InputGroupInput } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { WorkerCommandLine } from '../WorkerCommandLine' import { WorkersLogsColumnRender } from '@/components/interfaces/Settings/Logs/LogColumnRenderers/WorkersLogsColumnRender' +import { EXPLORER_DATEPICKER_HELPERS } from '@/components/interfaces/Settings/Logs/Logs.constants' +import { + LogsDatePicker, + type DatePickerValue, +} from '@/components/interfaces/Settings/Logs/Logs.DatePickers' import type { LogData } from '@/components/interfaces/Settings/Logs/Logs.types' import { LogTable } from '@/components/interfaces/Settings/Logs/LogTable' import { AlertError } from '@/components/ui/AlertError' @@ -15,6 +20,7 @@ import { workerLogsQueryOptions, type WorkerLogStream, } from '@/data/workers/worker-logs-query' +import { useDebouncedValue } from '@/hooks/misc/useDebouncedValue' import { CLI_NAME } from '@/lib/constants/workers' interface WorkerLogsTabProps { @@ -22,9 +28,23 @@ interface WorkerLogsTabProps { stream: WorkerLogStream } +const defaultDateRange = (): DatePickerValue => { + const helper = EXPLORER_DATEPICKER_HELPERS.find((helper) => helper.text === 'Last 24 hours')! + + return { + from: helper.calcFrom(), + to: helper.calcTo(), + isHelper: true, + text: helper.text, + } +} + export const WorkerLogsTab = ({ workerName, stream }: WorkerLogsTabProps) => { const { ref: projectRef } = useParams() const [selectedLog, setSelectedLog] = useState(null) + const [dateRange, setDateRange] = useState(defaultDateRange) + const [message, setMessage] = useState('') + const debouncedMessage = useDebouncedValue(message, 300) const { data: logs, @@ -33,13 +53,42 @@ export const WorkerLogsTab = ({ workerName, stream }: WorkerLogsTabProps) => { isError, isFetching, refetch, - } = useQuery(workerLogsQueryOptions({ projectRef, name: workerName, stream })) + } = useQuery( + workerLogsQueryOptions({ + projectRef, + name: workerName, + stream, + iso_timestamp_start: dateRange.from, + iso_timestamp_end: dateRange.to, + message: debouncedMessage, + }) + ) const label = WORKER_LOG_STREAM_LABEL[stream].toLowerCase() return (
-
+
+
+ + + setMessage(event.target.value)} + /> + + + + +