diff --git a/apps/docs/CONTRIBUTING.md b/apps/docs/CONTRIBUTING.md index 2c95fb79a3b..f1fe7b92ba7 100644 --- a/apps/docs/CONTRIBUTING.md +++ b/apps/docs/CONTRIBUTING.md @@ -172,7 +172,7 @@ Choose the appropriate `type` for your admonition: - `note` for anything else ``` - + Your content here diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index c9735fd3375..f87815936c9 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -1695,6 +1695,7 @@ export const functions: NavMenuConstant = { url: '/guides/functions/auth', items: [ { name: 'Securing your functions', url: '/guides/functions/auth' }, + { name: 'Authorization headers', url: '/guides/functions/auth-headers' }, { name: 'Legacy JWT secret', url: '/guides/functions/auth-legacy-jwt' }, ], }, diff --git a/apps/docs/content/guides/auth/auth-anonymous.mdx b/apps/docs/content/guides/auth/auth-anonymous.mdx index 2c9e1e606b9..c8d7f823f92 100644 --- a/apps/docs/content/guides/auth/auth-anonymous.mdx +++ b/apps/docs/content/guides/auth/auth-anonymous.mdx @@ -6,7 +6,7 @@ subtitle: 'Create and use anonymous users to authenticate with Supabase' [Enable Anonymous Sign-Ins](/dashboard/project/_/auth/providers) to build apps which provide users an authenticated experience without requiring users to enter an email address, password, use an OAuth provider or provide any other PII (Personally Identifiable Information). Later, when ready, the user can link an authentication method to their account. - + Calling `signInAnonymously()` creates an anonymous user. It's just like a permanent user, except the user can't access their account if they sign out, clear browsing data, or use another device. @@ -30,13 +30,13 @@ See the [Access control section](#access-control) for more details. - + The Supabase team has received reports of user metadata being cached across unique anonymous users as a result of Next.js static page rendering. For the best user experience, utilize dynamic page rendering. - + For self-hosting, you can update your project configuration using the files and environment variables provided. See the [local development docs](/docs/guides/cli/config) for more details. @@ -293,7 +293,7 @@ to authenticated using ( true ); ``` - + RLS policies are permissive by default, which means that they are combined using an "OR" operator when multiple policies are applied. It is important to construct restrictive policies to ensure that the checks for an anonymous user are always enforced when combined with other policies. Be aware that a single 'restrictive' RLS policy alone will fail unless combined with another policy that returns true, ensuring the combined condition is met. diff --git a/apps/docs/content/guides/auth/oauth-server/getting-started.mdx b/apps/docs/content/guides/auth/oauth-server/getting-started.mdx index 5304c8cbb74..1d40792deaf 100644 --- a/apps/docs/content/guides/auth/oauth-server/getting-started.mdx +++ b/apps/docs/content/guides/auth/oauth-server/getting-started.mdx @@ -238,6 +238,11 @@ export default async function ConsentPage({ return
Error: {error?.message || 'Invalid authorization request'}
} + // if no authorization_id returned, user has previously consented, redirect them + if (!('authorization_id' in authDetails)) { + redirect(authDetails['redirect_url']) + } + return (

Authorize {authDetails.client.name}

@@ -313,7 +318,7 @@ export async function POST(request: Request) { } // Redirect back to the client with authorization code - return NextResponse.redirect(data.redirect_to) + return NextResponse.redirect(data.redirect_url) } else { const { data, error } = await supabase.auth.oauth.denyAuthorization(authorizationId) @@ -322,7 +327,7 @@ export async function POST(request: Request) { } // Redirect back to the client with error - return NextResponse.redirect(data.redirect_to) + return NextResponse.redirect(data.redirect_url) } } ``` @@ -388,7 +393,7 @@ export function OAuthConsent() { setError(error.message) } else { // Redirect to client app - window.location.href = data.redirect_to + window.location.href = data.redirect_url } } @@ -401,7 +406,7 @@ export function OAuthConsent() { setError(error.message) } else { // Redirect to client app with error - window.location.href = data.redirect_to + window.location.href = data.redirect_url } } @@ -455,8 +460,8 @@ export function OAuthConsent() { 6. **Handle decision** - When the user clicks approve/deny: - Call `supabase.auth.oauth.approveAuthorization(authorization_id)` or `denyAuthorization(authorization_id)` - These methods handle all OAuth logic internally (generating authorization codes, etc.) - - They return a `redirect_to` URL -7. **Redirect back** - Redirect the user to the `redirect_to` URL, which sends them back to the third-party app with either an authorization code (approved) or error (denied) + - They return a `redirect_url` URL +7. **Redirect back** - Redirect the user to the `redirect_url` URL, which sends them back to the third-party app with either an authorization code (approved) or error (denied) ## Register an OAuth client diff --git a/apps/docs/content/guides/auth/oauth-server/oauth-flows.mdx b/apps/docs/content/guides/auth/oauth-server/oauth-flows.mdx index ec39a958843..04291fe8018 100644 --- a/apps/docs/content/guides/auth/oauth-server/oauth-flows.mdx +++ b/apps/docs/content/guides/auth/oauth-server/oauth-flows.mdx @@ -56,7 +56,7 @@ Here's a visual representation of the complete authorization code flow: │ (code_verifier, code_challenge) │ │ │ │ │ 2. Redirect to /oauth/authorize with code_challenge │ - ├────────────────────────────────────────────────────────────────>│ + ├───────────────────────────────────────────────────────────────>│ │ │ │ │ │ 3. Validate params & redirect │ │ │ to authorization_path │ @@ -71,7 +71,7 @@ Here's a visual representation of the complete authorization code flow: │ │ │ │ │ 6. approveAuthorization() │ │ ├────────────────────────────────>│ - │ │ Return redirect_to with code │ + │ │ Return redirect_url with code │ │ │<────────────────────────────────┤ │ │ │ │ 7. Redirect to client callback with code │ @@ -79,18 +79,18 @@ Here's a visual representation of the complete authorization code flow: │ │ │ │ 8. Exchange code for tokens (POST /oauth/token) │ │ with code_verifier │ - ├────────────────────────────────────────────────────────────────>│ + ├───────────────────────────────────────────────────────────────>│ │ │ │ │ 9. Return tokens (access, refresh, ID) │ - │<────────────────────────────────────────────────────────────────┤ + │<───────────────────────────────────────────────────────────────┤ │ │ │ │ 10. Access resources with access_token │ │ │ │ │ 11. Refresh tokens (POST /oauth/token with refresh_token) │ - ├────────────────────────────────────────────────────────────────>│ + ├───────────────────────────────────────────────────────────────>│ │ │ │ │ 12. Return new tokens │ - │<────────────────────────────────────────────────────────────────┤ + │<───────────────────────────────────────────────────────────────┤ │ │ │ ``` @@ -189,7 +189,7 @@ Your frontend application at the authorization path should: 5. **Handle user decision** - When the user approves or denies: - Call `supabase.auth.oauth.approveAuthorization(authorization_id)` to approve - Call `supabase.auth.oauth.denyAuthorization(authorization_id)` to deny - - Redirect user to the returned `redirect_to` URL + - Redirect user to the returned `redirect_url` URL This is a **frontend implementation** using `supabase-js`. Supabase Auth handles all the backend OAuth logic (generating authorization codes, validating requests, etc.) after you call the approve/deny methods. diff --git a/apps/docs/content/guides/auth/redirect-urls.mdx b/apps/docs/content/guides/auth/redirect-urls.mdx index 1175fd86dd6..050cbe1cf5f 100644 --- a/apps/docs/content/guides/auth/redirect-urls.mdx +++ b/apps/docs/content/guides/auth/redirect-urls.mdx @@ -42,7 +42,7 @@ Supabase allows you to specify wildcards when adding redirect URLs to the [allow The separator characters in a URL are defined as `.` and `/`. Use [this tool](https://www.digitalocean.com/community/tools/glob?comments=true&glob=http%3A%2F%2Flocalhost%3A3000%2F%2A%2A&matches=false&tests=http%3A%2F%2Flocalhost%3A3000&tests=http%3A%2F%2Flocalhost%3A3000%2F&tests=http%3A%2F%2Flocalhost%3A3000%2F%3Ftest%3Dtest&tests=http%3A%2F%2Flocalhost%3A3000%2Ftest-test%3Ftest%3Dtest&tests=http%3A%2F%2Flocalhost%3A3000%2Ftest%2Ftest%3Ftest%3Dtest) to test your patterns. - + While the "globstar" (`**`) is useful for local development and preview URLs, we recommend setting the exact redirect URL path for your site URL in production. diff --git a/apps/docs/content/guides/auth/social-login/auth-apple.mdx b/apps/docs/content/guides/auth/social-login/auth-apple.mdx index 81ad16a50cc..a419aa82a8c 100644 --- a/apps/docs/content/guides/auth/social-login/auth-apple.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-apple.mdx @@ -22,7 +22,7 @@ In some cases you're able to use the OAuth flow within web-based native apps suc When developing with Expo, you can test Sign in with Apple via the Expo Go app, in all other cases you will need to obtain an [Apple Developer](https://developer.apple.com) account to enable the capability. - + If you're using the OAuth flow (web, Flutter web, Kotlin non-iOS platforms), Apple requires you to generate a new secret key every 6 months using the signing key (`.p8` file). This is a critical maintenance task that will cause authentication failures if missed. @@ -35,7 +35,7 @@ This requirement only applies if you're configuring OAuth settings (Services ID, - + Apple's identity token does not include the user's full name in its claims. This means the Supabase Auth server cannot automatically populate the user's name metadata when users sign in with Apple. @@ -97,7 +97,7 @@ The platform-specific examples below demonstrate how to implement this pattern f This call takes the user to Apple's consent screen. Once the flow ends, the user's profile information is exchanged and validated with Supabase Auth before it redirects back to your web application with an access and refresh token representing the user's session. - + When using the OAuth flow, the user's full name is not accessible from Apple's response. Apple only provides the full name through native authentication methods (Sign in with Apple JS, or native iOS/macOS SDKs) during the first sign-in. @@ -371,7 +371,7 @@ curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ } ``` - + - Sign in with Apple is not natively available on Android devices - The OAuth flow opens a browser window for authentication diff --git a/apps/docs/content/guides/database/custom-postgres-config.mdx b/apps/docs/content/guides/database/custom-postgres-config.mdx index 22c1dceae66..4a2c30b7db6 100644 --- a/apps/docs/content/guides/database/custom-postgres-config.mdx +++ b/apps/docs/content/guides/database/custom-postgres-config.mdx @@ -208,7 +208,7 @@ select pg_postmaster_start_time(); You can also pass the `--no-restart` flag to attempt a reload-only apply. If the parameter cannot be reloaded, the change stays pending until the next restart. - + Postgres requires several parameters to be synchronized between the Primary cluster and [Read Replicas](/docs/guides/platform/read-replicas). diff --git a/apps/docs/content/guides/database/extensions/pg_net.mdx b/apps/docs/content/guides/database/extensions/pg_net.mdx index 29e4e47e703..d1a4505a50c 100644 --- a/apps/docs/content/guides/database/extensions/pg_net.mdx +++ b/apps/docs/content/guides/database/extensions/pg_net.mdx @@ -309,7 +309,7 @@ order by "created" desc; ## Configuration - + Supabase supports reconfiguring pg*net starting from v0.12.0+. For the latest release, initiate a Postgres upgrade in the [Infrastructure Settings](/dashboard/project/*/settings/infrastructure). diff --git a/apps/docs/content/guides/database/metabase.mdx b/apps/docs/content/guides/database/metabase.mdx index a2101c9e203..f7f0f17f3bd 100644 --- a/apps/docs/content/guides/database/metabase.mdx +++ b/apps/docs/content/guides/database/metabase.mdx @@ -43,7 +43,7 @@ hideToc: true - On your project dashboard, click [Connect](/dashboard/project/_?showConnect=true) - View parameters under "Session pooler" - + If you're in an [IPv6 environment](/docs/guides/platform/ipv4-address#checking-your-network-ipv6-support) or have the [IPv4 Add-On](/docs/guides/platform/ipv4-address#understanding-ip-addresses), you can use the direct connection string instead of Supavisor in Session mode. diff --git a/apps/docs/content/guides/database/postgres/row-level-security.mdx b/apps/docs/content/guides/database/postgres/row-level-security.mdx index 40fed201f49..ca895abf839 100644 --- a/apps/docs/content/guides/database/postgres/row-level-security.mdx +++ b/apps/docs/content/guides/database/postgres/row-level-security.mdx @@ -107,7 +107,7 @@ EXECUTE FUNCTION rls_auto_enable(); Note that this applies to tables created after the trigger is installed. Existing tables still need RLS enabled manually. - + When a request is made without an authenticated user (e.g., no access token is provided or the session has expired), `auth.uid()` returns `null`. @@ -156,7 +156,7 @@ to authenticated using ( true ); ``` - + Using the `anon` Postgres role is different from an [anonymous user](/docs/guides/auth/auth-anonymous) in Supabase Auth. An anonymous user assumes the `authenticated` role to access the database and can be differentiated from a permanent user by checking the `is_anonymous` claim in the JWT. diff --git a/apps/docs/content/guides/database/prisma.mdx b/apps/docs/content/guides/database/prisma.mdx index 1da56f1cdc5..31ea39f16e5 100644 --- a/apps/docs/content/guides/database/prisma.mdx +++ b/apps/docs/content/guides/database/prisma.mdx @@ -19,7 +19,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t - In the [SQL Editor](/dashboard/project/_/sql/new), create a Prisma DB user with full privileges on the public schema. - This gives you better control over Prisma's access and makes it easier to monitor using Supabase tools like the [Query Performance Dashboard](/dashboard/project/_/advisors/query-performance) and [Log Explorer](/dashboard/project/_/logs/explorer). - + For security, consider using a [password generator](https://bitwarden.com/password-generator/) for the Prisma role. @@ -283,7 +283,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t --to-schema prisma/schema.prisma \ --script > prisma/migrations/0_init_supabase/migration.sql ``` - + If there are any conflicts, reference [Prisma's official doc](https://www.prisma.io/docs/orm/prisma-migrate/getting-started#work-around-features-not-supported-by-prisma-schema-language) or the [trouble shooting guide](/docs/guides/database/prisma/prisma-troubleshooting) for more details @@ -311,7 +311,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t --to-schema prisma/schema.prisma \ --script > prisma/migrations/0_init_supabase/migration.sql ``` - + If there are any conflicts, reference [Prisma's official doc](https://www.prisma.io/docs/orm/prisma-migrate/getting-started#work-around-features-not-supported-by-prisma-schema-language) or the [trouble shooting guide](/docs/guides/database/prisma/prisma-troubleshooting) for more details @@ -339,7 +339,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t --to-schema prisma/schema.prisma \ --script > prisma/migrations/0_init_supabase/migration.sql ``` - + If there are any conflicts, reference [Prisma's official doc](https://www.prisma.io/docs/orm/prisma-migrate/getting-started#work-around-features-not-supported-by-prisma-schema-language) or the [trouble shooting guide](/docs/guides/database/prisma/prisma-troubleshooting) for more details @@ -367,7 +367,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t --to-schema prisma/schema.prisma \ --script > prisma/migrations/0_init_supabase/migration.sql ``` - + If there are any conflicts, reference [Prisma's official doc](https://www.prisma.io/docs/orm/prisma-migrate/getting-started#work-around-features-not-supported-by-prisma-schema-language) or the [trouble shooting guide](/docs/guides/database/prisma-troubleshooting) for more details diff --git a/apps/docs/content/guides/database/replication.mdx b/apps/docs/content/guides/database/replication.mdx index 305b8d5336c..53a92e048be 100644 --- a/apps/docs/content/guides/database/replication.mdx +++ b/apps/docs/content/guides/database/replication.mdx @@ -28,7 +28,7 @@ Read replicas are additional Supabase Postgres databases kept in sync with your ### External replication - + External replication is currently in private alpha. Access is limited and features may change. diff --git a/apps/docs/content/guides/database/replication/bigquery.mdx b/apps/docs/content/guides/database/replication/bigquery.mdx index 54a6d996b2d..c583f3a095e 100644 --- a/apps/docs/content/guides/database/replication/bigquery.mdx +++ b/apps/docs/content/guides/database/replication/bigquery.mdx @@ -6,7 +6,7 @@ subtitle: 'Replicate Supabase Postgres tables to BigQuery.' sidebar_label: 'BigQuery' --- - + External replication is currently in private alpha. Access is limited and features may change. diff --git a/apps/docs/content/guides/database/replication/external-replication-faq.mdx b/apps/docs/content/guides/database/replication/external-replication-faq.mdx index 424bcf64872..9a4c92f495f 100644 --- a/apps/docs/content/guides/database/replication/external-replication-faq.mdx +++ b/apps/docs/content/guides/database/replication/external-replication-faq.mdx @@ -6,7 +6,7 @@ subtitle: 'Common questions and answers about external replication.' sidebar_label: 'FAQ' --- - + External replication is currently in private alpha. Access is limited and features may change. diff --git a/apps/docs/content/guides/database/replication/external-replication-monitoring.mdx b/apps/docs/content/guides/database/replication/external-replication-monitoring.mdx index 5a03db9deec..12e460f88cf 100644 --- a/apps/docs/content/guides/database/replication/external-replication-monitoring.mdx +++ b/apps/docs/content/guides/database/replication/external-replication-monitoring.mdx @@ -6,7 +6,7 @@ subtitle: 'Track replication status, view logs, and troubleshoot issues.' sidebar_label: 'Monitoring' --- - + External replication is currently in private alpha. Access is limited and features may change. diff --git a/apps/docs/content/guides/database/replication/external-replication-setup.mdx b/apps/docs/content/guides/database/replication/external-replication-setup.mdx index 2b13ab71850..f1c58b4303d 100644 --- a/apps/docs/content/guides/database/replication/external-replication-setup.mdx +++ b/apps/docs/content/guides/database/replication/external-replication-setup.mdx @@ -6,7 +6,7 @@ subtitle: 'Configure publications and destinations for external replication.' sidebar_label: 'Setting up' --- - + External replication is currently in private alpha. Access is limited and features may change. diff --git a/apps/docs/content/guides/database/secure-data.mdx b/apps/docs/content/guides/database/secure-data.mdx index 8ca58f985a9..b1539b5f513 100644 --- a/apps/docs/content/guides/database/secure-data.mdx +++ b/apps/docs/content/guides/database/secure-data.mdx @@ -32,7 +32,7 @@ Your publishable key is safe to expose with RLS enabled, because row access perm Older projects may also show an `anon` key. Treat it like a publishable key: it can identify your project, but it is not a secret and must be paired with RLS and least-privilege grants. - + Unlike your publishable key, your secret and service role keys are **never** safe to expose because they bypass RLS. Only use your secret and service role keys on the backend. Treat them as secrets (for example, import them as sensitive environment variables instead of hardcoding them). diff --git a/apps/docs/content/guides/deployment/branching/configuration.mdx b/apps/docs/content/guides/deployment/branching/configuration.mdx index 128f8695925..e6c9f9df3b4 100644 --- a/apps/docs/content/guides/deployment/branching/configuration.mdx +++ b/apps/docs/content/guides/deployment/branching/configuration.mdx @@ -95,7 +95,7 @@ user = "env(SMTP_USER)" password = "env(SMTP_PASSWORD)" ``` - + Secrets set for one branch are not automatically available in other branches. You'll need to set them separately for each branch that needs them. @@ -152,7 +152,7 @@ client_id = "env(SUPABASE_AUTH_EXTERNAL_GITHUB_CLIENT_ID)" secret = "env(SUPABASE_AUTH_EXTERNAL_GITHUB_SECRET)" ``` - + The `encrypted:` syntax only works for designated "secret" fields in the configuration. Using encrypted values in other fields will not be automatically decrypted and may cause issues. For non-secret fields, use environment variables with the `env()` syntax instead. diff --git a/apps/docs/content/guides/deployment/branching/dashboard.mdx b/apps/docs/content/guides/deployment/branching/dashboard.mdx index a7577014d5a..e3367f382fe 100644 --- a/apps/docs/content/guides/deployment/branching/dashboard.mdx +++ b/apps/docs/content/guides/deployment/branching/dashboard.mdx @@ -5,7 +5,7 @@ subtitle: 'Create, manage, review, and merge branches directly in the dashboard' You can create, manage, review, and merge Supabase branches directly via the dashboard. This is useful for quick testing, prototyping, or when you prefer to work in a no-code way. You can also connect a Supabase branch to a GitHub branch at a later time if needed. - + Branch management via the dashboard is currently in public alpha. Features and functionality may change. diff --git a/apps/docs/content/guides/deployment/branching/integrations.mdx b/apps/docs/content/guides/deployment/branching/integrations.mdx index e1d89e75a9b..3e703dc892a 100644 --- a/apps/docs/content/guides/deployment/branching/integrations.mdx +++ b/apps/docs/content/guides/deployment/branching/integrations.mdx @@ -17,7 +17,7 @@ Install the Vercel integration: - From the [Vercel marketplace](https://vercel.com/integrations/supabase) or - By clicking the blue `Deploy` button in a Supabase example app's `README` file - + For branching to work with Vercel, you also need the [Vercel GitHub integration](https://vercel.com/docs/deployments/git/vercel-for-github). diff --git a/apps/docs/content/guides/deployment/going-into-prod.mdx b/apps/docs/content/guides/deployment/going-into-prod.mdx index a66683497cc..96bad598e91 100644 --- a/apps/docs/content/guides/deployment/going-into-prod.mdx +++ b/apps/docs/content/guides/deployment/going-into-prod.mdx @@ -76,7 +76,7 @@ Check and review issues in your database using [Performance Advisor](/dashboard/ ## Rate limiting, resource allocation, & abuse prevention - + Running databases is a shared responsibility between you and Supabase. There are some things that we can take care of for you, and some things that you are responsible for. diff --git a/apps/docs/content/guides/functions/auth-headers.mdx b/apps/docs/content/guides/functions/auth-headers.mdx new file mode 100644 index 00000000000..07efe0105f0 --- /dev/null +++ b/apps/docs/content/guides/functions/auth-headers.mdx @@ -0,0 +1,43 @@ +--- +id: 'auth-headers' +title: 'Authorization headers' +description: 'How the Authorization and apikey request headers and the verify_jwt platform check work for Edge Functions.' +subtitle: 'How the Authorization and apikey headers and the verify_jwt platform check work' +--- + +Every request to an Edge Function passes through two layers of auth. First, a platform-level check (`verify_jwt`) runs before your code executes. Then, once the request reaches your handler, you decide what to do with the credentials the caller sent. This page is the reference for both layers. For the practical patterns built on top of them, see [Securing Edge Functions](/guides/functions/auth). + +## Understanding authorization headers + +Edge Functions care about two request headers. Sending the wrong credential in the wrong header is the most common source of 401 errors. + +| Header | Value | Used for | +| --------------- | --------------------------------------- | -------------------------------------- | +| `Authorization` | `Bearer ` | A user signed in through Supabase Auth | +| `apikey` | `sb_publishable_...` or `sb_secret_...` | Calls from clients or services | + +A common mistake is sending a publishable or secret key as a bearer token: `Authorization: Bearer sb_publishable_...`. The new API keys are not JWTs. The platform check can't validate them, and your handler can't verify them as JWTs either. Instead, put API keys in the `apikey` header. + +You can send both headers together. A signed-in user calling your function through `supabase-js`, for example, sends their session JWT in `Authorization` and the project's publishable key in `apikey`. + +## The `verify_jwt` platform check + +When `verify_jwt` is enabled (the default), the platform inspects the `Authorization` header of every request before your function runs. It expects a valid user JWT. If the header is missing, malformed, or signed with a different key, the platform returns a 401 error, and your code never executes. + +The check validates legacy HS256 JWTs and JWTs signed with the new asymmetric [signing keys](/docs/guides/auth/signing-keys). + +The check does not accept an API key. Publishable and secret keys are not JWTs, so callers that send one in the `Authorization` header fail the check before their request reaches your handler. + +Use the `verify_jwt` flag to match how the function is called: + +- **Leave `verify_jwt` on** for functions that are only called with a user JWT, such as functions invoked from the client through `supabase.functions.invoke`. The platform rejects unauthenticated requests before they reach your code, and your handler can trust that a valid JWT is present. +- **Turn `verify_jwt` off** for functions that are called without an `Authorization` header, such as webhooks from external providers, or service-to-service calls that authenticate with an API key. These patterns are covered in [Securing Edge Functions](/guides/functions/auth). + +Set the flag per function in `supabase/config.toml`: + +```toml +[functions.stripe-webhook] +verify_jwt = false +``` + +For 401 failure modes and how to diagnose them, see [Edge Function 401 error response](/docs/troubleshooting/edge-function-401-error-response). diff --git a/apps/docs/content/guides/functions/auth.mdx b/apps/docs/content/guides/functions/auth.mdx index f6897e83ea6..071707aeaf1 100644 --- a/apps/docs/content/guides/functions/auth.mdx +++ b/apps/docs/content/guides/functions/auth.mdx @@ -5,218 +5,87 @@ description: 'Authentication patterns for Supabase Edge Functions.' subtitle: 'Authentication patterns for Edge Functions' --- - +The `withSupabase` wrapper from [`@supabase/server`](https://github.com/supabase/server) verifies the caller's credentials against a declared `auth` mode and hands you a pre-configured Supabase client on `ctx`. The sections below show how to use it for each common auth scenario. -The patterns in this guide assume your project uses the new [JWT signing keys](/docs/guides/getting-started/api-keys) and the new [API keys](https://github.com/orgs/supabase/discussions/29260). If you're still on legacy JWTs, see the [Legacy JWT Secret guide](/docs/guides/functions/auth-legacy-jwt). +For how authorization headers and the `verify_jwt` platform check work under the hood, see [Authorization headers](/docs/guides/functions/auth-headers). + +| Mode | Accepts | +| --------------- | ------------------------------------------ | +| `'user'` | A valid user JWT on `Authorization` | +| `'secret'` | A secret key on `apikey` | +| `'publishable'` | A publishable key on `apikey` | +| `'none'` | Any caller, no check (for signed webhooks) | + +## Authenticated user calls + +Functions called by signed-in users — typically through `supabase.functions.invoke` from the client — send the user's session JWT on the `Authorization` header. Keep `verify_jwt = true` (the default) so the platform validates the JWT before your handler runs, then use `auth: 'user'` to get `ctx.supabase` already scoped to the caller's RLS policies. + +```ts +import { withSupabase } from 'npm:@supabase/server' + +export default { + fetch: withSupabase({ auth: 'user' }, async (_req, ctx) => { + const { supabase, supabaseAdmin, userClaims, jwtClaims, authMode } = ctx + // supabase — RLS-scoped to the authenticated user + // supabaseAdmin — bypasses RLS (service role) + // userClaims — user identity from JWT (id, email, role) + // jwtClaims — full JWT claims + // authMode — which auth mode matched + + // your business logic goes here + return Response.json({ email: ctx.userClaims?.email }) + }), +} +``` + +## Service-to-service calls + +Cron jobs, workers, `pg_net`, or another Edge Function make calls with a secret key on the `apikey` header rather than a user JWT. Disable `verify_jwt` and use `auth: 'secret'` to validate the key against any secret key from your [dashboard](/dashboard/project/_/settings/api-keys). You get `ctx.supabaseAdmin` for privileged work. + +```ts +import { withSupabase } from 'npm:@supabase/server' + +export default { + fetch: withSupabase({ auth: 'secret' }, async (_req, ctx) => { + // your business logic. ctx.supabaseAdmin bypasses RLS + return Response.json({ ok: true }) + }), +} +``` + + + +To accept only one specific key, use `auth: 'secret:'`. For example, `auth: 'secret:automations'` only accepts the secret key you named "automations" in the [**Settings > API keys**](/dashboard/project/_/settings/api-keys) section of the Dashboard. The same syntax works for publishable keys (`auth: 'publishable:'`). + +![A secret key named "automations" listed under Secret keys in the Supabase dashboard.](/docs/img/guides/functions/secret-keys-automations.png) -Every request to an Edge Function passes through two layers of auth. First, a platform-level check (`verify_jwt`) runs before your code executes. Then, once the request reaches your handler, you decide what to do with the credentials the caller sent. +## Public functions -## Understanding authorization headers - -Edge Functions care about two request headers. Sending the wrong credential in the wrong header is the most common source of 401 errors. - -| Header | Value | Used for | -| --------------- | --------------------------------------- | -------------------------------------- | -| `Authorization` | `Bearer ` | A user signed in through Supabase Auth | -| `apikey` | `sb_publishable_...` or `sb_secret_...` | Calls from clients or services | - -A common mistake is sending a publishable or secret key as a bearer token: `Authorization: Bearer sb_publishable_...`. The new API keys are not JWTs. The platform check can't validate them, and your handler can't verify them as JWTs either. Instead, put API keys in the `apikey` header. - -You can send both headers together. A signed-in user calling your function through `supabase-js`, for example, sends their session JWT in `Authorization` and the project's publishable key in `apikey`. - -## The `verify_jwt` platform check - -When `verify_jwt` is enabled (the default), the platform inspects the `Authorization` header of every request before your function runs. It expects a valid user JWT. If the header is missing, malformed, or signed with a different key, the platform returns a 401 error, and your code never executes. - -The check validates legacy HS256 JWTs and JWTs signed with the new asymmetric [signing keys](/docs/guides/auth/signing-keys). - -The check does not accept an API key. Publishable and secret keys are not JWTs, so callers that send one in the `Authorization` header fail the check before their request reaches your handler. - -Use the `verify_jwt` flag to match how the function is called: - -- **Leave `verify_jwt` on** for functions that are only called with a user JWT, such as functions invoked from the client through `supabase.functions.invoke`. The platform rejects unauthenticated requests before they reach your code, and your handler can trust that a valid JWT is present. -- **Turn `verify_jwt` off** for functions that are called without an `Authorization` header, such as webhooks from external providers, or service-to-service calls that authenticate with an API key. These patterns are covered later in the guide. - -Set the flag per function in `supabase/config.toml`: - -```toml -[functions.stripe-webhook] -verify_jwt = false -``` - -For 401 failure modes and how to diagnose them, see [Edge Function 401 error response](/docs/troubleshooting/edge-function-401-error-response). - -## Common auth patterns - -The sections below show the four patterns you'll reach for most often, written without an SDK so the auth moves are visible. Business logic is left as a placeholder. The next section shows the same four patterns using [`@supabase/server`](https://github.com/supabase/server). - -### Authenticated user calls - -Keep `verify_jwt` enabled. The platform validates the JWT before your handler runs. Forward the `Authorization` header to the Supabase client so queries run under the caller's RLS policies. - -```toml -[functions.notes] -verify_jwt = true -``` - -```ts -import { createClient } from 'npm:@supabase/supabase-js@2' - -const SUPABASE_PUBLISHABLE_KEYS = JSON.parse(Deno.env.get('SUPABASE_PUBLISHABLE_KEYS')!) - -Deno.serve((req) => { - const supabase = createClient( - Deno.env.get('SUPABASE_URL')!, - SUPABASE_PUBLISHABLE_KEYS['default'], - { global: { headers: { Authorization: req.headers.get('Authorization')! } } } - ) - - // your business logic. queries run as the caller - return Response.json({ ok: true }) -}) -``` - -### Service-to-service calls - -Cron jobs, workers, `pg_net`, or another Edge Functions make calls with a secret key on the `apikey` header. These callers don't send a user JWT, so disable `verify_jwt` and validate the key yourself. - -```toml -[functions.run-automations] -verify_jwt = false -``` - -```ts -import { createClient } from 'npm:@supabase/supabase-js@2' - -const SUPABASE_SECRET_KEYS = JSON.parse(Deno.env.get('SUPABASE_SECRET_KEYS')!) - -Deno.serve((req) => { - if (req.headers.get('apikey') !== Deno.env.get('INTERNAL_AUTOMATIONS_KEY')) { - return Response.json({ error: 'forbidden' }, { status: 401 }) - } - - const supabase = createClient(Deno.env.get('SUPABASE_URL')!, SUPABASE_SECRET_KEYS['default']) - - // your business logic. queries run with the service role - return Response.json({ ok: true }) -}) -``` - - - -Never expose a secret key to the browser. Store it as a [function secret](/docs/guides/functions/secrets). - - - -### Public functions - -For a genuinely public function, like a health check, no credential is required. Disable `verify_jwt` so anonymous callers can reach the handler. +For a genuinely public function, like a health check, use `auth: 'none'` with `verify_jwt = false` so anonymous callers can reach the handler. ```toml [functions.health] verify_jwt = false ``` -```ts -Deno.serve(() => { - // your business logic - return Response.json({ ok: true }) -}) -``` - -### External webhooks - -External providers like Stripe or GitHub don't send Supabase credentials. They sign the request body with their own shared secret. Disable `verify_jwt` and verify the signature before acting on the payload. - -```toml -[functions.stripe-webhook] -verify_jwt = false -``` - -```ts -import Stripe from 'npm:stripe' - -const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!) - -Deno.serve(async (req) => { - const signature = req.headers.get('stripe-signature') ?? '' - const body = await req.text() - - try { - stripe.webhooks.constructEvent(body, signature, Deno.env.get('STRIPE_WEBHOOK_SECRET')!) - } catch { - return new Response('bad signature', { status: 400 }) - } - - // your business logic. handle the event - return Response.json({ received: true }) -}) -``` - -## Simplifying with `@supabase/server` - -The [`@supabase/server`](https://github.com/supabase/server) package wraps your handler, checks the caller's credentials against a declared `auth` mode, and hands you a pre-configured Supabase client on `ctx`. The same patterns above, written against the SDK, look like this. - -| Mode | Accepts | -| ---------------------- | ------------------------------------------ | -| `'user'` | A valid user JWT on `Authorization` | -| `'secret:'` | A named secret key on `apikey` | -| `'publishable:'` | A named publishable key on `apikey` | -| `'none'` | Any caller, no check (for signed webhooks) | - - - -See the [`@supabase/server` docs](https://github.com/supabase/server) for the full list of modes. - - - -### Authenticated user calls [#authenticated-user-calls-with-server-sdk] - -`auth: 'user'` pairs with `verify_jwt = true`. The platform validates the JWT, and the SDK hands you `ctx.supabase` already scoped to the caller. - ```ts import { withSupabase } from 'npm:@supabase/server' export default { - fetch: withSupabase({ auth: 'user' }, async (_req, ctx) => { - // your business logic. ctx.supabase is scoped to the caller - return Response.json({ email: ctx.userClaims?.email }) - }), -} -``` - -### Service-to-service calls [#service-to-service-calls-with-server-sdk] - -`auth: 'secret:'` validates the `apikey` header against the named secret key from your [dashboard](/dashboard/project/_/settings/api-keys) and gives you `ctx.supabaseAdmin` for privileged work. The `` matches the name you gave the key. Keep `verify_jwt = false`. - -```ts -import { withSupabase } from 'npm:@supabase/server' - -export default { - fetch: withSupabase({ auth: 'secret:automations' }, async (_req, ctx) => { - // your business logic. ctx.supabaseAdmin bypasses RLS + fetch: withSupabase({ auth: 'none' }, async () => { + // your business logic return Response.json({ ok: true }) }), } ``` - +`auth: 'none'` skips every credential check — see the caution under [External webhooks](#external-webhooks) before using it on anything that reads or writes sensitive data. -Create a named secret key for each caller in the [**Settings > API keys**](/dashboard/project/_/settings/api-keys) section of the Dashboard. Give it a name like "automations", and share the generated `sb_secret_...` value with the service that calls this function. +## External webhooks -![A secret key named "automations" listed under Secret keys in the Supabase dashboard.](/docs/img/guides/functions/secret-keys-automations.png) - - - -### Public functions [#public-functions-with-server-sdk] - -The SDK adds nothing to a truly public function. Use the raw pattern from the previous section. If you need a Supabase client anyway, `auth: 'none'` with `verify_jwt = false` skips every check and treats every caller as anonymous. - -### External webhooks [#external-webhooks-with-server-sdk] - -Use `auth: 'none'` to skip the SDK's credential check, then verify the provider's signature inside the handler. Keep `verify_jwt = false`. +External providers like Stripe or GitHub don't send Supabase credentials. They sign the request body with their own shared secret. Use `auth: 'none'` to skip the SDK's credential check, then verify the provider's signature inside the handler. Keep `verify_jwt = false`. ```ts import { withSupabase } from 'npm:@supabase/server' @@ -247,7 +116,7 @@ export default { -### Combining modes +## Combining modes Functions that answer both users and internal callers take an array on `auth`. Modes are tried in order. The first match wins, and `ctx.authMode` tells you which matched. @@ -255,7 +124,7 @@ Functions that answer both users and internal callers take an array on `auth`. M import { withSupabase } from 'npm:@supabase/server' export default { - fetch: withSupabase({ auth: ['user', 'secret:automations'] }, async (req, ctx) => { + fetch: withSupabase({ auth: ['user', 'secret'] }, async (req, ctx) => { if (ctx.authMode === 'user') { // your business logic for user calls. ctx.supabase is scoped to them return Response.json({ ok: true }) @@ -267,7 +136,7 @@ export default { } ``` -### Custom error responses +## Custom error responses To shape the 401 response yourself, use `createSupabaseContext` instead of `withSupabase`. It returns a `{ data, error }` tuple so you stay in control. diff --git a/apps/docs/content/guides/functions/deploy.mdx b/apps/docs/content/guides/functions/deploy.mdx index 038d379281f..e5ccb12e30d 100644 --- a/apps/docs/content/guides/functions/deploy.mdx +++ b/apps/docs/content/guides/functions/deploy.mdx @@ -34,7 +34,7 @@ Get the project ID associated with your function: supabase projects list ``` - + If you haven't yet created a Supabase project, you can do so by visiting [database.new](https://database.new). diff --git a/apps/docs/content/guides/functions/development-tips.mdx b/apps/docs/content/guides/functions/development-tips.mdx index 6a333357c42..edddd412140 100644 --- a/apps/docs/content/guides/functions/development-tips.mdx +++ b/apps/docs/content/guides/functions/development-tips.mdx @@ -11,7 +11,7 @@ Here are a few recommendations when you first start developing Edge Functions. Edge Functions support `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, and `OPTIONS`. A Function can be designed to perform different actions based on a request's HTTP method. See the [example on building a RESTful service](https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/restful-tasks) to learn how to handle different HTTP methods in your Function. - + HTML content is not supported. `GET` requests that return `text/html` will be rewritten to `text/plain`. diff --git a/apps/docs/content/guides/functions/quickstart-dashboard.mdx b/apps/docs/content/guides/functions/quickstart-dashboard.mdx index 16ab71e2baf..7fae4fc918f 100644 --- a/apps/docs/content/guides/functions/quickstart-dashboard.mdx +++ b/apps/docs/content/guides/functions/quickstart-dashboard.mdx @@ -9,13 +9,13 @@ Supabase allows you to create Supabase Edge Functions directly from the Supabase This guide will walk you through creating, testing, and deploying your first Edge Function using the Supabase Dashboard. You'll have a working function running globally in under 10 minutes. - + You can also create and deploy functions using the Supabase CLI. Check out our [CLI Quickstart guide](/docs/guides/functions/quickstart). - + You'll need a Supabase project to get started. If you don't have one yet, create a new project at [database.new](https://database.new/). @@ -50,7 +50,7 @@ width={2338} height={926} /> - + The dashboard offers several pre-built templates for common use cases, such as Stripe Webhooks, OpenAI proxying, uploading files to Supabase Storage, and sending emails. @@ -139,7 +139,7 @@ To invoke this Edge Function from within your application, you'll need API keys. If you’d like to update the deployed function code, click on the function you want to edit, modify the code as needed, then click Deploy updates. This will overwrite the existing deployment with the newly edited function code. - + There is currently **no version control** for edits! The Dashboard's Edge Function editor currently does not support version control, versioning, or rollbacks. We recommend using it only for quick testing and prototypes. @@ -241,7 +241,7 @@ Now that your function is deployed, you can access it from your local developmen ### CLI - + Before getting started, make sure you have the **Supabase CLI installed**. Check out the [CLI installation guide](/docs/guides/cli) for installation methods and troubleshooting. diff --git a/apps/docs/content/guides/functions/quickstart.mdx b/apps/docs/content/guides/functions/quickstart.mdx index ef9fb77823f..3d9c3e064e4 100644 --- a/apps/docs/content/guides/functions/quickstart.mdx +++ b/apps/docs/content/guides/functions/quickstart.mdx @@ -53,7 +53,7 @@ supabase functions new hello-world {/* TODO: Link to parameter documentation */} - + When an HTTP request is sent to Edge Functions, you can use Supabase Auth to secure endpoints. By default, the `supabase functions new` command adds handling a valid publishable or secret key to the basic template. However, you can change this behavior with the `--auth` flag when creating a new function. @@ -132,7 +132,7 @@ After this step, you should have successfully tested your Edge Function locally To deploy your function globally, you need to connect your local project to a Supabase project. - + Create one at [database.new](https://database.new/). @@ -176,7 +176,7 @@ If you want to deploy all functions, run the `deploy` command without specifying supabase functions deploy ``` - + The CLI automatically falls back to API-based deployment if Docker isn't available. You can also explicitly use API deployment with the `--use-api` flag: diff --git a/apps/docs/content/guides/functions/status-codes.mdx b/apps/docs/content/guides/functions/status-codes.mdx index 49e51ee9f5b..e10548aa25a 100644 --- a/apps/docs/content/guides/functions/status-codes.mdx +++ b/apps/docs/content/guides/functions/status-codes.mdx @@ -109,7 +109,7 @@ You can see the output in the [Edge Function Logs](/docs/guides/functions/loggin ### 546 Resource Limit (Custom Error Code) -**Cause:** Your Edge Function execution was stopped due to exceeding resource limits (`WORKER_LIMIT`). Edge Function logs should provide which [resource limit](/docs/guides/functions/limits) was exceeded. +**Cause:** Your Edge Function execution was stopped due to exceeding resource limits (`WORKER_RESOURCE_LIMIT`, previously it was `WORKER_LIMIT`). Edge Function logs should provide which [resource limit](/docs/guides/functions/limits) was exceeded. **Common causes:** diff --git a/apps/docs/content/guides/getting-started/tutorials/with-angular.mdx b/apps/docs/content/guides/getting-started/tutorials/with-angular.mdx index 9ad548dfa13..f6b075f0485 100644 --- a/apps/docs/content/guides/getting-started/tutorials/with-angular.mdx +++ b/apps/docs/content/guides/getting-started/tutorials/with-angular.mdx @@ -58,7 +58,7 @@ Optionally, update `src/styles.css` to style the app. You can find the full cont You need an Angular component to manage logins and sign ups. The component uses [Magic Links](/docs/guides/auth/auth-email-passwordless#with-magic-link), so users can sign in with their email without using passwords. - + You can customize other emails sent out to new users, including the email's looks, content, and query parameters from [the **Authentication > Email**](/dashboard/project/_/auth/templates) section of the Dashboard. diff --git a/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx b/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx index 6487f311807..82e348a086d 100644 --- a/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx +++ b/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx @@ -182,7 +182,7 @@ Before proceeding, change the email template to support a server-side authentica - Select the **Confirm signup** template. - Change `{{ .ConfirmationURL }}` to `{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email`. - + You can customize other emails sent out to new users, including the email's looks, content, and query parameters from [the **Authentication > Email**](/dashboard/project/_/auth/templates) section of the Dashboard. diff --git a/apps/docs/content/guides/getting-started/tutorials/with-react.mdx b/apps/docs/content/guides/getting-started/tutorials/with-react.mdx index f740c3a7c8e..690f32feb0b 100644 --- a/apps/docs/content/guides/getting-started/tutorials/with-react.mdx +++ b/apps/docs/content/guides/getting-started/tutorials/with-react.mdx @@ -62,7 +62,7 @@ You can find the full contents of this file [in the example repository](https:// You need a React component to manage logins and sign-ups. It uses [Magic Links](/docs/guides/auth/auth-email-passwordless#with-magic-link), so users can sign in with their email without using passwords. - + You can customize other emails sent out to new users, including the email's looks, content, and query parameters from [the **Authentication > Email**](/dashboard/project/_/auth/templates) section of the Dashboard. diff --git a/apps/docs/content/guides/getting-started/tutorials/with-sveltekit.mdx b/apps/docs/content/guides/getting-started/tutorials/with-sveltekit.mdx index a6f8b9bd743..f46e2edd0bd 100644 --- a/apps/docs/content/guides/getting-started/tutorials/with-sveltekit.mdx +++ b/apps/docs/content/guides/getting-started/tutorials/with-sveltekit.mdx @@ -169,7 +169,7 @@ Before proceeding, change the email template to support sending a token hash: - Change `{{ .ConfirmationURL }}` to `{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email`. - Repeat the previous step for **Magic link** template. - + You can also customize emails sent out to new users, including the email's looks, content, and query parameters. Check out the [settings of your project](/dashboard/project/_/auth/templates). diff --git a/apps/docs/content/guides/local-development/cli/getting-started.mdx b/apps/docs/content/guides/local-development/cli/getting-started.mdx index f3675c13895..8b4fb90c14d 100644 --- a/apps/docs/content/guides/local-development/cli/getting-started.mdx +++ b/apps/docs/content/guides/local-development/cli/getting-started.mdx @@ -234,7 +234,7 @@ npm update supabase@beta --save-dev If you have any Supabase containers running locally, stop them and delete their data volumes before proceeding with the upgrade. This ensures that Supabase managed services can apply new migrations on a clean state of the local database. - + Remember to save any local schema and data changes before stopping because the `--no-backup` flag will delete them. diff --git a/apps/docs/content/guides/platform/backups.mdx b/apps/docs/content/guides/platform/backups.mdx index 82091f9a6cb..6d437b241f5 100644 --- a/apps/docs/content/guides/platform/backups.mdx +++ b/apps/docs/content/guides/platform/backups.mdx @@ -17,7 +17,7 @@ When you delete a project, we permanently remove all associated data, including Database backups can be categorized into two types: **logical** and **physical**. You can learn more about them [in this blog post](/blog/postgresql-physical-logical-backups). - + All projects on Postgres `15.8.1.079` and newer use the newer physical backup process. diff --git a/apps/docs/content/guides/platform/clone-project.mdx b/apps/docs/content/guides/platform/clone-project.mdx index dcc3abcdb06..833091c0f1b 100644 --- a/apps/docs/content/guides/platform/clone-project.mdx +++ b/apps/docs/content/guides/platform/clone-project.mdx @@ -3,7 +3,7 @@ title: Restore to a new project subtitle: How to clone your existing Supabase project --- - + You can clone your Supabase project by restoring your data from an existing project into a completely new one. This process creates a database-only copy and requires manual reconfiguration to fully replicate your original project. diff --git a/apps/docs/content/guides/platform/compute-and-disk.mdx b/apps/docs/content/guides/platform/compute-and-disk.mdx index 0c19b903841..84063513865 100644 --- a/apps/docs/content/guides/platform/compute-and-disk.mdx +++ b/apps/docs/content/guides/platform/compute-and-disk.mdx @@ -10,7 +10,7 @@ Every project on the Supabase Platform comes with its own dedicated Postgres ins The following table describes the base instances, Nano (free plan) and Micro (paid plans), with additional compute instance sizes available if you need extra performance when scaling up. - + In paid organizations, Nano Compute are billed at the same price as Micro Compute. It is recommended to upgrade your Project from Nano Compute to Micro Compute when it's convenient for you. Compute sizes are not auto-upgraded because of the downtime incurred. See [Supabase Pricing](/pricing) for more information. You cannot launch Nano instances on paid plans, only Micro and above - but you might have Nano instances after upgrading from Free Plan. diff --git a/apps/docs/content/guides/platform/manage-your-usage/compute.mdx b/apps/docs/content/guides/platform/manage-your-usage/compute.mdx index 525b5ec5b33..1e228e41f35 100644 --- a/apps/docs/content/guides/platform/manage-your-usage/compute.mdx +++ b/apps/docs/content/guides/platform/manage-your-usage/compute.mdx @@ -61,7 +61,7 @@ Paid plans include in Compute Credits, which cover one proj [^1]: Compute resources on the Free Plan are subject to change. - + In paid organizations, Nano Compute are billed at the same price as Micro Compute. It is recommended to upgrade your Project from Nano Compute to Micro Compute when it's convenient for you. Compute sizes are not auto-upgraded because of the downtime incurred. See [Supabase Pricing](/pricing) for more information. You cannot launch Nano instances on paid plans, only Micro and above - but you might have Nano instances after upgrading from Free Plan. diff --git a/apps/docs/content/guides/platform/migrating-to-supabase/auth0.mdx b/apps/docs/content/guides/platform/migrating-to-supabase/auth0.mdx index 5bd2ffc10ce..c8f9825d7ef 100644 --- a/apps/docs/content/guides/platform/migrating-to-supabase/auth0.mdx +++ b/apps/docs/content/guides/platform/migrating-to-supabase/auth0.mdx @@ -90,7 +90,7 @@ Migrate existing users to Supabase Auth. This requires two main steps: first, ch }) ``` - + Supabase supports bcrypt and Argon2 password hashes. diff --git a/apps/docs/content/guides/platform/sso.mdx b/apps/docs/content/guides/platform/sso.mdx index 655ff458d1f..8c5e8059cc1 100644 --- a/apps/docs/content/guides/platform/sso.mdx +++ b/apps/docs/content/guides/platform/sso.mdx @@ -29,7 +29,7 @@ Once configured, you can update your settings anytime from [the **SSO** section] ![SSO Example](/docs/img/sso-dashboard-enabled-idp.png) - + After configuring your SSO provider, thorough testing is essential. See our [SSO Testing and Best Practices](/docs/guides/platform/sso/testing-best-practices) guide for: @@ -100,13 +100,13 @@ When SSO is enabled for an organization: 6. Thoroughly test using our [SSO Testing and Best Practices](/docs/guides/platform/sso/testing-best-practices) guide 7. Invite users to the organization or let them auto-join on login - + If a user is already a member of the organization under a non-SSO account, they will need to be removed and invited again with an SSO-required invitation to join under their SSO account. SSO and non-SSO accounts with the same email are treated as separate accounts. - + Each user account verified using a SSO identity provider will not be automatically linked to existing user accounts in the system. That is, if a user `valid.email@supabase.io` had signed up with a password, and then uses their company SSO login with your project, there will be two `valid.email@supabase.io` user accounts in the system. @@ -118,7 +118,7 @@ Users will need to ensure they are logged in with the correct account when accep If you disable or delete the SSO provider for an organization, **all SSO users will immediately be unable to sign in**. - + The system requires at least one non-SSO owner account before allowing SSO provider deletion. This prevents complete organization lockout. When you delete an SSO provider, all SSO members are automatically removed from the organization. diff --git a/apps/docs/content/guides/platform/sso/azure.mdx b/apps/docs/content/guides/platform/sso/azure.mdx index 1794628df08..62d0a7ef3eb 100644 --- a/apps/docs/content/guides/platform/sso/azure.mdx +++ b/apps/docs/content/guides/platform/sso/azure.mdx @@ -164,7 +164,7 @@ Before rolling out SSO to your organization, we strongly recommend thorough test - Security best practices - Pre-launch checklist - + If your organization has an Azure sandbox or test tenant, consider testing your SSO configuration there first before applying to production. diff --git a/apps/docs/content/guides/platform/sso/choosing-login-flow.mdx b/apps/docs/content/guides/platform/sso/choosing-login-flow.mdx index 986bdbce3d5..8816d78ac1c 100644 --- a/apps/docs/content/guides/platform/sso/choosing-login-flow.mdx +++ b/apps/docs/content/guides/platform/sso/choosing-login-flow.mdx @@ -240,7 +240,7 @@ Do users need to start login at supabase.com? 4. Use **non-SSO invitations** for contractors 5. Consider disabling auto-join to control membership - + SSO and non-SSO accounts with the same email are treated as separate accounts. An employee with `alice@company.com` will have two accounts if they: @@ -306,7 +306,7 @@ If you're uncertain which configuration to use: 4. Enable SP-initiated if users request it 5. Monitor usage to see which flow is preferred - + If you need help choosing the right configuration for your organization, contact Supabase support with details about your use case. We're happy to provide personalized recommendations. diff --git a/apps/docs/content/guides/platform/sso/login-flows.mdx b/apps/docs/content/guides/platform/sso/login-flows.mdx index 54db12195a8..806efb9c9ea 100644 --- a/apps/docs/content/guides/platform/sso/login-flows.mdx +++ b/apps/docs/content/guides/platform/sso/login-flows.mdx @@ -5,7 +5,7 @@ description: 'Learn about IdP-initiated and SP-initiated SSO login flows and whe When configuring SSO for your organization, you can choose between two different login flows: **identity provider (IdP)-initiated** and **service provider (SP)-initiated**. Understanding the difference helps you provide the best experience for your users. - + Most enterprises use IdP-initiated flow for its simplicity and better user experience. Enable SP-initiated only if you need users to start their login journey at supabase.com. @@ -107,7 +107,7 @@ IdP-initiated flow is automatically enabled when you configure SSO. No additiona Users can now access Supabase through your IdP's app catalog. - + With IdP-initiated flow, you don't need to configure email domains. Your identity provider handles all authentication routing. @@ -131,7 +131,7 @@ To enable SP-initiated flow, you need to configure email domains: - Multiple domains supported (e.g., `company.com`, `subsidiary.com`) - Users with matching email domains will be routed to your IdP - + Only users with email addresses matching your configured domains can use SP-initiated login. Users with other domains cannot sign in via SSO at supabase.com (but can still use IdP-initiated flow if you configure it in your IdP). diff --git a/apps/docs/content/guides/platform/sso/multiple-providers.mdx b/apps/docs/content/guides/platform/sso/multiple-providers.mdx index c9b9dc54d9c..3c28ee06e70 100644 --- a/apps/docs/content/guides/platform/sso/multiple-providers.mdx +++ b/apps/docs/content/guides/platform/sso/multiple-providers.mdx @@ -21,7 +21,7 @@ The traditional challenge with multiple SAML apps is domain conflicts. With SP-i **Solution:** Use identity provider (IdP)-initiated flow, which doesn't require domain configuration. You can create unlimited SAML apps under the same domain. - + Configure each environment as IdP-initiated only (no domains). Users access each environment through different app tiles in your identity provider. @@ -126,7 +126,7 @@ Configure both organizations with SP-initiated enabled using the same domain: - System routes based on org membership (first match wins) - Also provide IDP tiles for explicit routing - + When multiple organizations use SP-initiated with the same domain, the first provider where the user is a member will be used. This can cause confusion. **IDP-initiated is recommended** for clarity. diff --git a/apps/docs/content/guides/realtime/limits.mdx b/apps/docs/content/guides/realtime/limits.mdx index 5aea01f8202..edea5d6dc2d 100644 --- a/apps/docs/content/guides/realtime/limits.mdx +++ b/apps/docs/content/guides/realtime/limits.mdx @@ -35,7 +35,7 @@ When you exceed a limit, errors will appear in the backend logs and client-side - **Logs**: check the [Realtime logs](/dashboard/project/_/database/realtime-logs) inside your project Dashboard. - **WebSocket errors**: Use your browser's developer tools to find the WebSocket initiation request and view individual messages. - + You can use the [Realtime Inspector](https://realtime.supabase.com/inspector/new) to reproduce an error and share those connection details with Supabase support. diff --git a/apps/docs/content/guides/self-hosting/docker.mdx b/apps/docs/content/guides/self-hosting/docker.mdx index 22fdba57a4f..7e906e6a4ef 100644 --- a/apps/docs/content/guides/self-hosting/docker.mdx +++ b/apps/docs/content/guides/self-hosting/docker.mdx @@ -172,7 +172,7 @@ Review and change URL configuration variables: - `API_EXTERNAL_URL`: used by the Auth service to configure callback URLs, e.g., `http://example.com:8000` - `SITE_URL`: default [redirect URL](/docs/guides/auth/redirect-urls) for Auth, e.g., `http://example.com:3000` - + Throughout the self-hosting guides, `` stands for the host where your Supabase instance is reachable: your domain name, your server's IP, or `localhost`, depending on your setup. @@ -236,7 +236,7 @@ To stop Supabase, use: docker compose down ``` - + If the API gateway (Kong) fails to start with an entrypoint error, your local files may have been checked out with CRLF line endings instead of LF. Re-clone the repository, or normalize everything in the `docker/` directory to LF, then restart Supabase. Fresh clones should already use LF because of `.gitattributes`. diff --git a/apps/docs/content/guides/self-hosting/postgres-upgrade-17.mdx b/apps/docs/content/guides/self-hosting/postgres-upgrade-17.mdx index e61b7e7bde6..ef94ac11a7a 100644 --- a/apps/docs/content/guides/self-hosting/postgres-upgrade-17.mdx +++ b/apps/docs/content/guides/self-hosting/postgres-upgrade-17.mdx @@ -58,7 +58,7 @@ Upgrading an existing deployment uses `pg_upgrade` to migrate data in place. The ### Create a backup - + You should create your own independent backup in case of disk failure or other issues. diff --git a/apps/docs/content/guides/self-hosting/self-hosted-proxy-https.mdx b/apps/docs/content/guides/self-hosting/self-hosted-proxy-https.mdx index 5d4df0ceda2..cbdaebfb1c4 100644 --- a/apps/docs/content/guides/self-hosting/self-hosted-proxy-https.mdx +++ b/apps/docs/content/guides/self-hosting/self-hosted-proxy-https.mdx @@ -18,7 +18,7 @@ You need: Below are two options for adding a reverse proxy with automatic HTTPS in front of your self-hosted Supabase: **Caddy** (simpler, zero-config TLS) and **Nginx + Let's Encrypt** (more control over proxy settings). Both sit in front of the API gateway and terminate TLS, so internal traffic stays on HTTP. - + If you already run [HAProxy](https://www.haproxy.com/), [Traefik](https://traefik.io/), [Nginx Proxy Manager](https://nginxproxymanager.com/), or another reverse proxy for your infrastructure, you can use it instead of Caddy or Nginx above. The key requirements are: @@ -31,7 +31,7 @@ If you already run [HAProxy](https://www.haproxy.com/), [Traefik](https://traefi - + Envoy is an optional [API gateway](/docs/guides/self-hosting/self-hosted-envoy), enabled via the `docker-compose.envoy.yml` override. If you already run Envoy instead of Kong, edit `docker-compose.caddy.yml` or `docker-compose.nginx.yml` to comment out the `kong:` block and uncomment the `api-gw:` block (and the matching `depends_on` entry) so the reverse proxy sits in front of Envoy. diff --git a/apps/docs/content/guides/storage/s3/authentication.mdx b/apps/docs/content/guides/storage/s3/authentication.mdx index bdd21c52270..879f3f11149 100644 --- a/apps/docs/content/guides/storage/s3/authentication.mdx +++ b/apps/docs/content/guides/storage/s3/authentication.mdx @@ -13,7 +13,7 @@ You have two options to authenticate with Supabase Storage S3: ## S3 access keys - + S3 access keys provide full access to all S3 operations across all buckets and bypass RLS policies. These are meant to be used only on the server. diff --git a/apps/docs/content/troubleshooting/edge-function-546-error-response.mdx b/apps/docs/content/troubleshooting/edge-function-546-error-response.mdx index cca513f9a48..5a632cc7455 100644 --- a/apps/docs/content/troubleshooting/edge-function-546-error-response.mdx +++ b/apps/docs/content/troubleshooting/edge-function-546-error-response.mdx @@ -1,15 +1,19 @@ --- -title = "546 - WORKER_LIMIT Exceeded" +title = "546 - WORKER_RESOURCE_LIMIT Exceeded / WORKER_LIMIT Exceeded" topics = [ "functions" ] keywords = [ "546", "error", "resource", "memory", "cpu", "event loop", "edge function" ] database_id = "4e6ff2e4-2abb-4fba-8233-5883b3d56fb0" +[[errors]] +http_status_code = 546 +message = "WORKER_RESOURCE_LIMIT" + [[errors]] http_status_code = 546 message = "WORKER_LIMIT" --- -A 546 error indicates that an edge function used more resources (CPU or Memory) than it was allocated. +A 546 error indicates that an edge function used more resources (CPU or Memory) than it was allocated. Previously it was `WORKER_LIMIT`. ## Context for the error @@ -37,7 +41,7 @@ When an edge function fails due to internal CPU or memory limits, it will return ```json { - "code": "WORKER_LIMIT", + "code": "WORKER_RESOURCE_LIMIT", "message": "Function failed due to not having enough compute resources (please check logs)" } ``` diff --git a/apps/docs/content/troubleshooting/http-status-codes.mdx b/apps/docs/content/troubleshooting/http-status-codes.mdx index b81da31d103..299287c0dff 100644 --- a/apps/docs/content/troubleshooting/http-status-codes.mdx +++ b/apps/docs/content/troubleshooting/http-status-codes.mdx @@ -70,4 +70,4 @@ The timeout limit is set to prevent long-running queries which can cause perform #### 546 Edge Functions resource limit -Applies only to Edge Functions. Function execution was stopped due to a resource limit (`WORKER_LIMIT`). Edge Function logs should provide which [resource limit](/docs/guides/functions/limits) was exceeded. +Applies only to Edge Functions. Function execution was stopped due to a resource limit (`WORKER_RESOURCE_LIMIT`). Edge Function logs should provide which [resource limit](/docs/guides/functions/limits) was exceeded. diff --git a/apps/docs/docs/ref/self-hosting-analytics/introduction.mdx b/apps/docs/docs/ref/self-hosting-analytics/introduction.mdx index f6ebb948d81..2c2c31ab4be 100644 --- a/apps/docs/docs/ref/self-hosting-analytics/introduction.mdx +++ b/apps/docs/docs/ref/self-hosting-analytics/introduction.mdx @@ -18,7 +18,7 @@ The Supabase Analytics server is a Logflare self-hostable instance that manages When self-hosting the Analytics server, the full logging experience matching that of the Supabase Platform is available in the Studio instance, allowing for an integrated and enhanced development experience. However, it's important to note that certain [differences](#differences) may arise due to the platform's infrastructure. - + All Logflare technical documentation is available at https://docs.logflare.app. diff --git a/apps/docs/docs/ref/self-hosting-functions/introduction.mdx b/apps/docs/docs/ref/self-hosting-functions/introduction.mdx index 64661800631..70ae44e906d 100644 --- a/apps/docs/docs/ref/self-hosting-functions/introduction.mdx +++ b/apps/docs/docs/ref/self-hosting-functions/introduction.mdx @@ -21,7 +21,7 @@ You can use it to: - Locally test and self-host Supabase's Edge Functions (or any Deno Edge Function) - As a programmable HTTP Proxy: You can intercept / route HTTP requests - + Self hosted Edge functions are in beta. There will be breaking changes to APIs / Configuration Options. diff --git a/apps/studio/components/grid/components/grid/Grid.tsx b/apps/studio/components/grid/components/grid/Grid.tsx index f6df2b27017..50f38de5c2a 100644 --- a/apps/studio/components/grid/components/grid/Grid.tsx +++ b/apps/studio/components/grid/components/grid/Grid.tsx @@ -25,10 +25,9 @@ import { formatForeignKeys } from '@/components/interfaces/TableGridEditor/SideP import { useForeignKeyConstraintsQuery } from '@/data/database/foreign-key-constraints-query' import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants' import { isTableLike } from '@/data/table-editor/table-editor-types' -import { useSendEventMutation } from '@/data/telemetry/send-event-mutation' -import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { useCsvFileDrop } from '@/hooks/ui/useCsvFileDrop' +import { useTrack } from '@/lib/telemetry/track' import { useTableEditorStateSnapshot } from '@/state/table-editor' import { useTableEditorTableStateSnapshot } from '@/state/table-editor-table' import { ResponseError } from '@/types' @@ -69,7 +68,6 @@ export const Grid = memo( const snap = useTableEditorTableStateSnapshot() const { filters, clearFilters } = useTableFilter() - const { data: org } = useSelectedOrganizationQuery() const { data: project } = useSelectedProjectQuery() const onRowsChange = useOnRowsChange(rows) @@ -92,20 +90,12 @@ export const Grid = memo( const isForeignTable = tableEntityType === ENTITY_TYPE.FOREIGN_TABLE const isTableEmpty = (rows ?? []).length === 0 - const { mutate: sendEvent } = useSendEventMutation() + const track = useTrack() const { isDraggedOver, onDragOver, onFileDrop } = useCsvFileDrop({ enabled: isTableEmpty && !isForeignTable, onFileDropped: (file) => tableEditorSnap.onImportData(valtioRef(file)), - onTelemetryEvent: (eventName) => { - sendEvent({ - action: eventName, - groups: { - project: project?.ref ?? 'Unknown', - organization: org?.slug ?? 'Unknown', - }, - }) - }, + onTelemetryEvent: (eventName) => track(eventName), }) const { data } = useForeignKeyConstraintsQuery({ @@ -339,14 +329,7 @@ export const Grid = memo( className="pointer-events-auto" onClick={() => { tableEditorSnap.onImportData() - sendEvent({ - action: 'import_data_button_clicked', - properties: { tableType: 'Existing Table' }, - groups: { - project: project?.ref ?? 'Unknown', - organization: org?.slug ?? 'Unknown', - }, - }) + track('import_data_button_clicked', { tableType: 'Existing Table' }) }} > Import data from CSV diff --git a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx index 22a4cb613c1..617db464b69 100644 --- a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx +++ b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx @@ -4,12 +4,11 @@ import { PropsWithChildren } from 'react' import { OrganizationResourceBanner } from '../Organization/HeaderBanner' import { ClockSkewBanner } from '@/components/layouts/AppLayout/ClockSkewBanner' import { FlyDeprecationBanner } from '@/components/layouts/AppLayout/FlyDeprecationBanner' -import { NoticeBanner, NoticeBanner2 } from '@/components/layouts/AppLayout/NoticeBanner' +import { NoticeBanner } from '@/components/layouts/AppLayout/NoticeBanner' import { StatusPageBanner } from '@/components/layouts/AppLayout/StatusPageBanner' export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => { const showNoticeBanner = useFlag('showNoticeBanner') - const showNoticeBanner2 = useFlag('showNoticeBanner2') const clockSkewBanner = useFlag('clockSkewBanner') return ( @@ -17,7 +16,6 @@ export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => {
{showNoticeBanner && } - {showNoticeBanner2 && } {/* Disabled until reintroduced or removed altogether. */} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts index 8f1caab1aa2..874fdfcb336 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts +++ b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts @@ -35,10 +35,10 @@ export const useFeaturePreviews = (): FeaturePreview[] => { }, { key: LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS, - name: 'New Logs interface', + name: 'Updated Logs interface', discussionsUrl: 'https://github.com/orgs/supabase/discussions/37234', enabled: isUnifiedLogsPreviewAvailable || isEnterpriseOrSupabaseOrg, - isNew: false, + isNew: true, isPlatformOnly: true, isDefaultOptIn: false, }, diff --git a/apps/studio/components/interfaces/Auth/RLSTester/RLSTesterSheet.tsx b/apps/studio/components/interfaces/Auth/RLSTester/RLSTesterSheet.tsx index 42a243b2a14..098a170034f 100644 --- a/apps/studio/components/interfaces/Auth/RLSTester/RLSTesterSheet.tsx +++ b/apps/studio/components/interfaces/Auth/RLSTester/RLSTesterSheet.tsx @@ -13,7 +13,7 @@ import { SelectTrigger, SelectValue, } from '@ui/components/shadcn/ui/select' -import { LOCAL_STORAGE_KEYS } from 'common' +import { LOCAL_STORAGE_KEYS, useFlag } from 'common' import { Code, ExternalLink } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import { @@ -35,6 +35,7 @@ import { type ParseQueryResults } from './RLSTester.types' import { RLSTesterEmptyState } from './RLSTesterEmptyState' import { RLSTesterResults } from './RLSTesterResults' import { RoleSelector } from './RoleSelector' +import { SandboxManagement } from './SandboxManagement' import { UserSelector } from './UserSelector' import { UserSqlEditor } from './UserSqlEditor' import { useTestQueryRLS } from './useTestQueryRLS' @@ -44,6 +45,7 @@ import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown' import { FeaturePreviewBadge } from '@/components/ui/FeaturePreviewBadge' import { useTrack } from '@/lib/telemetry/track' import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' +import { PostgresSandboxProvider } from '@/state/postgres-sandbox/sandbox' import { useRoleImpersonationStateSnapshot } from '@/state/role-impersonation-state' import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' @@ -51,11 +53,20 @@ interface RLSTesterSheetProps { handleSelectEditPolicy: (policy: Policy) => void } -export const RLSTesterSheet = ({ handleSelectEditPolicy }: RLSTesterSheetProps) => { +export const RLSTesterSheet = (props: RLSTesterSheetProps) => { + return ( + + + + ) +} + +const RLSTesterSheetContents = ({ handleSelectEditPolicy }: RLSTesterSheetProps) => { const track = useTrack() const aiSnap = useAiAssistantStateSnapshot() const { openSidebar } = useSidebarManagerSnapshot() const { setRole } = useRoleImpersonationStateSnapshot() + const sandboxEnabled = useFlag('rlsTesterSandbox') const [open, setOpen] = useState(false) const [selectedOption, setSelectedOption] = useState<'anon' | 'authenticated'>('anon') @@ -132,12 +143,13 @@ export const RLSTesterSheet = ({ handleSelectEditPolicy }: RLSTesterSheetProps) } useEffect(() => { - setRole({ type: 'postgrest', role: 'anon' }) - // Flip back to service role - return () => { + if (open) { + setRole({ type: 'postgrest', role: 'anon' }) + } else { + // Flip back to service role setRole(undefined) } - }, [setRole]) + }, [open, setRole]) return ( @@ -159,7 +171,9 @@ export const RLSTesterSheet = ({ handleSelectEditPolicy }: RLSTesterSheetProps)
- + {sandboxEnabled && } + +
{selectedOption === 'authenticated' && } diff --git a/apps/studio/components/interfaces/Auth/RLSTester/SandboxManagement.tsx b/apps/studio/components/interfaces/Auth/RLSTester/SandboxManagement.tsx new file mode 100644 index 00000000000..a7ce45ebcc9 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/RLSTester/SandboxManagement.tsx @@ -0,0 +1,105 @@ +import { Box, Loader2, LogOut, RefreshCw } from 'lucide-react' +import { Badge, Button } from 'ui' +import { Admonition } from 'ui-patterns' + +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import { usePostgresSandbox } from '@/state/postgres-sandbox/sandbox' + +export const SandboxManagement = () => { + const { status, error, isSyncing, startSandbox, destroySandbox, syncSandbox } = + usePostgresSandbox() + + if (status === 'idle') { + return ( + startSandbox()}> + Set up sandbox + , + ]} + > +
+

Set up sandbox for testing

+ Recommended +
+

+ Ensure that queries do not affect your actual database +

+
+ ) + } + + if (status === 'loading') { + return ( + +
+
+ +
+

Setting up sandbox

+
+
+ ) + } + + if (status === 'error') { + return ( + startSandbox()}> + Retry set up + , + ]} + /> + ) + } + + return ( + } + className="w-7" + disabled={isSyncing} + tooltip={{ content: { side: 'bottom', text: 'Exit sandbox' } }} + onClick={() => destroySandbox()} + />, + } + className="w-7" + loading={isSyncing} + tooltip={{ content: { side: 'bottom', text: 'Refresh schema' } }} + onClick={() => syncSandbox()} + />, + ]} + > +
+
+ +
+

Sandbox active

+

Your database is never modified

+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Auth/RLSTester/useTestQueryRLS.ts b/apps/studio/components/interfaces/Auth/RLSTester/useTestQueryRLS.ts index 5ddf6516c7e..91ede81b5a0 100644 --- a/apps/studio/components/interfaces/Auth/RLSTester/useTestQueryRLS.ts +++ b/apps/studio/components/interfaces/Auth/RLSTester/useTestQueryRLS.ts @@ -12,6 +12,7 @@ import { useParseSQLQueryMutation } from '@/data/misc/parse-query-mutation' import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { wrapWithRoleImpersonation } from '@/lib/role-impersonation' +import { usePostgresSandbox } from '@/state/postgres-sandbox/sandbox' import { isRoleImpersonationEnabled, useGetImpersonatedRoleState, @@ -35,20 +36,23 @@ export const useTestQueryRLS = () => { const { data: project } = useSelectedProjectQuery() const { role } = useRoleImpersonationStateSnapshot() + const { sandbox } = usePostgresSandbox() const getImpersonatedRoleState = useGetImpersonatedRoleState() const impersonatedRoleState = getImpersonatedRoleState() const user = useImpersonatedUser() const [isLoading, setIsLoading] = useState(false) + const [sandboxError, setSandboxError] = useState() const { data: policies = [] } = useDatabasePoliciesQuery({ projectRef: project?.ref, connectionString: project?.connectionString, }) - const { mutateAsync: executeSql, error: executeSqlError } = useExecuteSqlMutation({ + const { mutateAsync: executeSql, error: executeSqlMutationError } = useExecuteSqlMutation({ onError: () => {}, }) + const executeSqlError = sandbox ? sandboxError : executeSqlMutationError const { mutateAsync: parseClientCode, @@ -104,13 +108,14 @@ export const useTestQueryRLS = () => { try { setIsLoading(true) + setSandboxError(undefined) const { appendAutoLimit } = checkIfAppendLimitRequired(value, limit) const formattedSql = suffixWithLimit(value, limit) const data = await parseQuery({ sql: formattedSql }) if (data.operation !== 'SELECT') { - return toast('Only SELECT statements are supported for now') + return toast('Only SELECT statements are supported with the RLS Tester at the moment') } const formattedTables = data.tables.map((x) => { @@ -146,19 +151,25 @@ export const useTestQueryRLS = () => { }) const autoLimit = appendAutoLimit ? limit : undefined - const { result } = await executeSql({ - autoLimit, - projectRef: project.ref, - connectionString: project.connectionString, - sql: wrapWithRoleImpersonation(formattedSql, impersonatedRoleState), - isRoleImpersonationEnabled: isRoleImpersonationEnabled(impersonatedRoleState.role), - isStatementTimeoutDisabled: true, - handleError: (error) => { - throw error - }, - queryKey: ['rls-tester'], - }) + const sql = wrapWithRoleImpersonation(formattedSql, impersonatedRoleState) + const { result } = sandbox + ? await sandbox.run({ sql }).catch((e) => { + setSandboxError(e instanceof Error ? e : new Error(String(e))) + throw e + }) + : await executeSql({ + sql, + autoLimit, + projectRef: project.ref, + connectionString: project.connectionString, + isRoleImpersonationEnabled: isRoleImpersonationEnabled(impersonatedRoleState.role), + isStatementTimeoutDisabled: true, + handleError: (e) => { + throw e + }, + queryKey: ['rls-tester'], + }) onExecuteSQL({ result, isAutoLimit: !!autoLimit }) onParseQuery({ diff --git a/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx b/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx index 1e847c0e004..06811897b55 100644 --- a/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx @@ -181,7 +181,7 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => { {isBanned ? ( ) : ( diff --git a/apps/studio/components/interfaces/Connect/OrganizationSelector.tsx b/apps/studio/components/interfaces/Connect/OrganizationSelector.tsx new file mode 100644 index 00000000000..7d2a98f7216 --- /dev/null +++ b/apps/studio/components/interfaces/Connect/OrganizationSelector.tsx @@ -0,0 +1,180 @@ +import { LOCAL_STORAGE_KEYS } from 'common' +import { Check, ChevronDown } from 'lucide-react' +import { useMemo, useState, type ReactNode } from 'react' +import { cn, Collapsible, CollapsibleContent, CollapsibleTrigger } from 'ui' + +import { + CreateOrganizationCard, + OrganizationCard, +} from '@/components/interfaces/Organization/OrganizationCard' +import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' +import type { Organization } from '@/types' + +const VISIBLE_ORGANIZATIONS_LIMIT = 3 +const CONNECT_DISCLOSURE_TRIGGER_CLASSNAME = + 'mx-auto flex h-7 cursor-pointer items-center justify-center gap-1.5 rounded-md px-2 text-xs text-foreground-lighter transition-colors hover:bg-surface-200 hover:text-foreground' + +export const OrganizationSelector = ({ + organizations, + selectedSlug, + disabled = false, + description, + createLabel, + createHrefParams, + onSelect, + getOrganizationDescription, +}: { + organizations: Organization[] + selectedSlug?: string | null + disabled?: boolean + description?: ReactNode + createLabel?: string + createHrefParams?: { [key: string]: string } + onSelect: (slug: string) => void + getOrganizationDescription?: (organization: Organization) => ReactNode +}) => { + const [showMore, setShowMore] = useState(false) + const [lastVisitedOrganization] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION, + '' + ) + + const { visibleOrganizations, overflowOrganizations } = useMemo(() => { + const lastVisitedOrg = organizations.find(({ slug }) => slug === lastVisitedOrganization) + const selectedIndex = organizations.findIndex(({ slug }) => slug === selectedSlug) + const selectedInOverflow = selectedIndex >= VISIBLE_ORGANIZATIONS_LIMIT + + if (!!lastVisitedOrg) { + const withoutLastVisited = organizations.filter( + ({ slug }) => slug !== lastVisitedOrganization + ) + return { + visibleOrganizations: [ + lastVisitedOrg, + ...withoutLastVisited.slice(0, VISIBLE_ORGANIZATIONS_LIMIT - 1), + ], + overflowOrganizations: withoutLastVisited.slice(VISIBLE_ORGANIZATIONS_LIMIT - 1), + } + } + + if (!selectedInOverflow || !selectedSlug) { + return { + visibleOrganizations: organizations.slice(0, VISIBLE_ORGANIZATIONS_LIMIT), + overflowOrganizations: organizations.slice(VISIBLE_ORGANIZATIONS_LIMIT), + } + } + + const selected = organizations[selectedIndex] + const withoutSelected = organizations.filter(({ slug }) => slug !== selectedSlug) + + return { + visibleOrganizations: [ + ...withoutSelected.slice(0, VISIBLE_ORGANIZATIONS_LIMIT - 1), + selected, + ], + overflowOrganizations: withoutSelected.slice(VISIBLE_ORGANIZATIONS_LIMIT - 1), + } + }, [lastVisitedOrganization, organizations, selectedSlug]) + + const hasOverflow = overflowOrganizations.length > 0 + + return ( +
+
+

+ Organization +

+ {description &&

{description}

} +
+
+ {visibleOrganizations.map((organization) => ( + onSelect(organization.slug)} + description={ + getOrganizationDescription?.(organization) ?? getPlanDescription(organization) + } + /> + ))} + + {!!createLabel && !!createHrefParams && ( + + )} + + {hasOverflow && ( + + + {showMore ? 'Show fewer' : `Show ${overflowOrganizations.length} more`} + + + +
+ {overflowOrganizations.map((organization) => ( + onSelect(organization.slug)} + description={ + getOrganizationDescription?.(organization) ?? getPlanDescription(organization) + } + /> + ))} +
+
+
+ )} +
+
+ ) +} + +const getPlanDescription = (organization: Organization) => `${organization.plan.name} Plan` + +const ConnectOrganizationButton = ({ + organization, + selected, + disabled, + onClick, + description, +}: { + organization: Organization + selected?: boolean + disabled?: boolean + onClick?: () => void + description?: ReactNode +}) => ( + +) diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx index 62b6bc38a47..00390fd5d37 100644 --- a/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx +++ b/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx @@ -63,28 +63,27 @@ export const PublicationsTables = () => { return ( <> -
-
- } - className="w-48" - placeholder="Search for a table" - value={filterString} - onChange={(e) => setFilterString(e.target.value)} - onKeyDown={onSearchInputEscape(filterString, setFilterString)} - /> - {!isLoadingPermissions && !canUpdatePublications && ( - - )} -
+
+ } + className="w-48" + placeholder="Search for a table" + value={filterString} + onChange={(e) => setFilterString(e.target.value)} + onKeyDown={onSearchInputEscape(filterString, setFilterString)} + />
+ {!isLoadingPermissions && !canUpdatePublications && ( + + )} + diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationPanelFields.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationPanelFields.tsx index 62fe93a1913..0638af48351 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationPanelFields.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationPanelFields.tsx @@ -639,21 +639,26 @@ export const AnalyticsBucketFields = ({

{isSuccessKeys && keyNoLongerExists && ( - -

- Please select another key or create a new set, as this destination will not - work otherwise. S3 access keys can be managed in your{' '} - - storage settings - -

-
+ + Please select another key or create a new set, as this destination will + not work otherwise. S3 access keys can be managed in your{' '} + + storage settings + + . + + } + /> )} {s3AccessKeyId === CREATE_NEW_KEY && ( )} @@ -719,7 +724,7 @@ export const AnalyticsBucketFields = ({ layout="horizontal" label="S3 Secret Access Key" className="relative" - description="The secret key corresponding to your selected access key ID" + description="The secret key corresponding to your selected access key ID." > { return ( ) } diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/DeleteCronJob.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/DeleteCronJob.tsx index c59bfbef728..ae9a700053f 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/DeleteCronJob.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/DeleteCronJob.tsx @@ -3,17 +3,16 @@ import { useEffect } from 'react' import { toast } from 'sonner' import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' +import { parseCronJobCommand } from './CronJobs.utils' import { useCronJobsData } from './CronJobsTab.useCronJobsData' import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper' import { useDatabaseCronJobDeleteMutation } from '@/data/database-cron-jobs/database-cron-jobs-delete-mutation' -import { useSendEventMutation } from '@/data/telemetry/send-event-mutation' -import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { cleanPointerEventsNoneOnBody } from '@/lib/helpers' +import { useTrack } from '@/lib/telemetry/track' export const DeleteCronJob = () => { const { data: project } = useSelectedProjectQuery() - const { data: org } = useSelectedOrganizationQuery() const [searchQuery] = useQueryState('search', parseAsString.withDefault('')) const [cronJobIdForDeletion, setCronJobForDeletion] = useQueryState('delete', parseAsString) @@ -25,17 +24,17 @@ export const DeleteCronJob = () => { }) const cronJob = grid.rows.find((j) => j.jobid.toString() === cronJobIdForDeletion) - const { mutate: sendEvent } = useSendEventMutation() + const track = useTrack() const { mutate: deleteDatabaseCronJob, isPending, isSuccess: isSuccessDelete, } = useDatabaseCronJobDeleteMutation({ onSuccess: () => { - sendEvent({ - action: 'cron_job_removed', - groups: { project: project?.ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, - }) + if (cronJob && project) { + const { type } = parseCronJobCommand(cronJob.command, project.ref) + track('cron_job_removed', { type }) + } toast.success(`Successfully removed cron job`) setCronJobForDeletion(null) }, diff --git a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx index 451ec22cd4b..3d08b838b4f 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx @@ -281,7 +281,7 @@ export const QueueSettings = ({}: QueueSettingsProps) => { )}
diff --git a/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/StripeSyncSettingsPage.tsx b/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/StripeSyncSettingsPage.tsx index af152e75464..f21fc6f5201 100644 --- a/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/StripeSyncSettingsPage.tsx +++ b/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/StripeSyncSettingsPage.tsx @@ -32,7 +32,7 @@ export const StripeSyncSettingsPage = () => { return ( - + ) diff --git a/apps/studio/components/interfaces/LogDrains/LogDrains.tsx b/apps/studio/components/interfaces/LogDrains/LogDrains.tsx index af68874c678..0c773fc5b50 100644 --- a/apps/studio/components/interfaces/LogDrains/LogDrains.tsx +++ b/apps/studio/components/interfaces/LogDrains/LogDrains.tsx @@ -210,7 +210,7 @@ export function LogDrains({ onConfirm={() => { if (selectedLogDrain && ref) { deleteLogDrain({ token: selectedLogDrain.token, projectRef: ref }) - track('log_drain_confirm_button_submitted', { + track('log_drain_removed', { destination: selectedLogDrain.type, }) } diff --git a/apps/studio/components/interfaces/Organization/Documents/DPA.tsx b/apps/studio/components/interfaces/Organization/Documents/DPA.tsx index a00b50aaab5..87a2b5b7ef6 100644 --- a/apps/studio/components/interfaces/Organization/Documents/DPA.tsx +++ b/apps/studio/components/interfaces/Organization/Documents/DPA.tsx @@ -10,9 +10,9 @@ import { import { InlineLink } from '@/components/ui/InlineLink' import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper' import { useDpaRequestMutation } from '@/data/documents/dpa-request-mutation' -import { useSendEventMutation } from '@/data/telemetry/send-event-mutation' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useProfile } from '@/lib/profile' +import { useTrack } from '@/lib/telemetry/track' export const DPA = () => { const { profile } = useProfile() @@ -21,7 +21,7 @@ export const DPA = () => { const [isOpen, setIsOpen] = useState(false) - const { mutate: sendEvent } = useSendEventMutation() + const track = useTrack() const { mutate: requestDpa, isPending: isRequesting } = useDpaRequestMutation({ onSuccess: () => { toast.success('DPA request sent successfully') @@ -49,12 +49,7 @@ export const DPA = () => { You can review a static PDF version of our latest DPA document{' '} - sendEvent({ - action: 'dpa_pdf_opened', - properties: { source: 'studio' }, - }) - } + onClick={() => track('dpa_pdf_opened', { source: 'studio' })} > here @@ -67,9 +62,7 @@ export const DPA = () => { +

+ Credits apply to one organization and are used toward future invoices before your + payment method is charged. +

+ + + + + {redemptionModalOrgSlug && ( + setRedemptionModalOrgSlug(null)} + /> + )} + + ) +} + +const ConnectLoadingCards = () => ( +
+ + + +
+ + +
+
+
+
+ + {Array.from({ length: 3 }).map((_, index) => ( + + + +
+ + +
+
+
+ ))} +
+
+ + +
+
+) diff --git a/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx b/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx index bc9f40ede61..c1d3fcdaf0e 100644 --- a/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx +++ b/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx @@ -272,7 +272,7 @@ export const PostgrestConfig = () => { ) : isError ? ( - + ) : ( <> diff --git a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx index 47dcbecab1e..0bf56a72a55 100644 --- a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx @@ -161,7 +161,7 @@ const IPv4SidePanel = () => { {!isAws && ( )} diff --git a/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessRuleSheet.tsx b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessRuleSheet.tsx index 9c39fa42954..8b360cacef4 100644 --- a/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessRuleSheet.tsx +++ b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessRuleSheet.tsx @@ -306,7 +306,7 @@ export function JitDbAccessRuleSheet({ {grants.length === 0 ? ( ) : ( diff --git a/apps/studio/components/interfaces/Settings/General/DashboardPreferences.tsx b/apps/studio/components/interfaces/Settings/General/DashboardPreferences.tsx index cce9e5b592e..7eb72fa9de3 100644 --- a/apps/studio/components/interfaces/Settings/General/DashboardPreferences.tsx +++ b/apps/studio/components/interfaces/Settings/General/DashboardPreferences.tsx @@ -96,7 +96,7 @@ export const DashboardPreferences = () => { {/* [Joshen] Ideally we're able to persist this for all users in the project, but will need support in our middleware */} {isLoading ? ( diff --git a/apps/studio/components/interfaces/Support/ProjectAndPlanInfo.tsx b/apps/studio/components/interfaces/Support/ProjectAndPlanInfo.tsx index 5afc2f7a769..af6ef46f857 100644 --- a/apps/studio/components/interfaces/Support/ProjectAndPlanInfo.tsx +++ b/apps/studio/components/interfaces/Support/ProjectAndPlanInfo.tsx @@ -40,7 +40,9 @@ export function ProjectAndPlanInfo({ - {!hasProjectSelected && } + {!hasProjectSelected && ( + + )} ) } diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ApiAccessToggle.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ApiAccessToggle.tsx index 12153250543..29fcb1824a1 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ApiAccessToggle.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ApiAccessToggle.tsx @@ -403,7 +403,10 @@ const SchemaExposureOptions = ({ return ( <> {isError && ( - + )} {isSchemaExposed && apiUrl && ( diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx index 8202f78b302..7d255fcd1e7 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx @@ -306,7 +306,7 @@ export const UnifiedLogs = () => { setSearch(search) } - const debouncedApplyFilterSearch = useDebounce(applyFilterSearch, 1000) + const debouncedApplyFilterSearch = useDebounce(applyFilterSearch, 250) useEffect(() => { debouncedApplyFilterSearch() diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx index b1e55f44974..98574cd488d 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx @@ -102,6 +102,7 @@ export function generateDynamicColumns({ data }: { data: ColumnSchema[] }): { meta: { cellClassName: 'font-mono tracking-tight w-[140px]', headerClassName: 'w-[140px]', + dataType: 'date', }, }, // Log type column - always visible diff --git a/apps/studio/components/layouts/AppLayout/NoticeBanner.tsx b/apps/studio/components/layouts/AppLayout/NoticeBanner.tsx index 2421f7697c4..ccd6fee5974 100644 --- a/apps/studio/components/layouts/AppLayout/NoticeBanner.tsx +++ b/apps/studio/components/layouts/AppLayout/NoticeBanner.tsx @@ -1,4 +1,3 @@ -import { useQueries } from '@tanstack/react-query' import { LOCAL_STORAGE_KEYS } from 'common' import { useRouter } from 'next/router' import { @@ -18,12 +17,6 @@ import { import { HeaderBanner } from '@/components/interfaces/Organization/HeaderBanner' import { InlineLink, InlineLinkClassName } from '@/components/ui/InlineLink' -import { useOrganizationsQuery } from '@/data/organizations/organizations-query' -import { projectKeys } from '@/data/projects/keys' -import { - getOrganizationProjects, - type OrgProject, -} from '@/data/projects/org-projects-infinite-query' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' // Update this whenever the banner content below changes so old client bundles @@ -60,77 +53,6 @@ export const NoticeBanner = () => { ) } -const MAINTENANCE_REGIONS = new Set(['ap-southeast-1', 'sa-east-1']) - -export const NoticeBanner2 = () => { - const id = 'maintenance-2026-05-13' - const expiry = new Date('2026-05-14T23:59:00Z') - const isExpired = new Date() > expiry - - const router = useRouter() - - const [bannerAcknowledged, setBannerAcknowledged, { isSuccess }] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.MAINTENANCE_BANNER_DISMISSED(id), - false - ) - - const shouldEvaluate = - !router.pathname.includes('sign-in') && isSuccess && !bannerAcknowledged && !isExpired - - const { data: organizations } = useOrganizationsQuery({ enabled: shouldEvaluate }) - const orgProjectsQueries = useQueries({ - queries: (organizations ?? []).map((org) => ({ - queryKey: projectKeys.bannerProjectsByOrg(org.slug), - queryFn: () => getOrganizationProjects({ slug: org.slug, limit: 100 }), - staleTime: 30 * 60 * 1000, - enabled: shouldEvaluate, - })), - }) - - const isProjectsFetched = - organizations !== undefined && - (organizations.length === 0 || orgProjectsQueries.every((q) => q.isFetched)) - - const hasMaintenanceRegionProject = orgProjectsQueries - .flatMap((q) => q.data?.projects ?? []) - .some((project: OrgProject) => - project.databases.some((db) => MAINTENANCE_REGIONS.has(db.region)) - ) - - if (!shouldEvaluate || !isProjectsFetched || !hasMaintenanceRegionProject) { - return null - } - - return ( - - Shared pooler maintenance in{' '} - - ap-southeast-1 - {' '} - and{' '} - - sa-east-1 - {' '} - on May 13-14. - - } - onDismiss={() => setBannerAcknowledged(true)} - /> - ) -} - const UpdatedTermsOfServiceDialog = ({ onDismiss }: { onDismiss: () => void }) => { return ( diff --git a/apps/studio/components/layouts/Navigation/LayoutHeader/HeaderUpgradeButton.tsx b/apps/studio/components/layouts/Navigation/LayoutHeader/HeaderUpgradeButton.tsx index f7f833d716d..e4de5957c25 100644 --- a/apps/studio/components/layouts/Navigation/LayoutHeader/HeaderUpgradeButton.tsx +++ b/apps/studio/components/layouts/Navigation/LayoutHeader/HeaderUpgradeButton.tsx @@ -4,7 +4,8 @@ import { useTrackExperimentExposure } from '@/hooks/misc/useTrackExperimentExpos import { usePHFlag } from '@/hooks/ui/useFlag' import { useTrack } from '@/lib/telemetry/track' -const EXPERIMENT_ID = 'headerUpgradeCta' +const EXPERIMENT_FLAG_KEY = 'headerUpgradeCta' +const EXPERIMENT_EXPOSURE_NAME = 'header_upgrade_cta' type HeaderUpgradeCtaVariant = 'control' | 'test' interface HeaderUpgradeButtonProps { @@ -14,15 +15,14 @@ interface HeaderUpgradeButtonProps { export const HeaderUpgradeButton = ({ className }: HeaderUpgradeButtonProps) => { const track = useTrack() const { data: organization } = useSelectedOrganizationQuery() - const flagValue = usePHFlag(EXPERIMENT_ID) + const flagValue = usePHFlag(EXPERIMENT_FLAG_KEY) const isFreePlan = organization?.plan?.id === 'free' const isInExperiment = flagValue === 'control' || flagValue === 'test' const showButton = flagValue === 'test' - // Track experiment exposure for all free-plan users in the experiment (both control and test) const variant = isFreePlan && isInExperiment ? (flagValue as string) : undefined - useTrackExperimentExposure(EXPERIMENT_ID, variant) + useTrackExperimentExposure(EXPERIMENT_EXPOSURE_NAME, variant) if (!isFreePlan) return null if (!showButton) return null @@ -31,5 +31,7 @@ export const HeaderUpgradeButton = ({ className }: HeaderUpgradeButtonProps) => track('header_upgrade_cta_clicked') } - return + return ( + + ) } diff --git a/apps/studio/components/layouts/RedeemCreditsLayout.tsx b/apps/studio/components/layouts/RedeemCreditsLayout.tsx deleted file mode 100644 index 16c276802c5..00000000000 --- a/apps/studio/components/layouts/RedeemCreditsLayout.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { useTheme } from 'next-themes' -import Head from 'next/head' -import Image from 'next/legacy/image' -import type { PropsWithChildren } from 'react' -import { Separator } from 'ui' - -import { withAuth } from '../../hooks/misc/withAuth' -import { useCustomContent } from '@/hooks/custom-content/useCustomContent' -import { BASE_PATH } from '@/lib/constants' - -export interface RedeemCreditsLayoutProps {} - -const RedeemCreditsLayout = ({ children }: PropsWithChildren) => { - const { resolvedTheme } = useTheme() - const { appTitle } = useCustomContent(['app:title']) - - return ( - <> - - Redeem Credits | {appTitle || 'Supabase'} - -
-
-
-
-
-
- Supabase - -
-
-
-
-
- -
- {children} -
-
- - ) -} - -export default withAuth(RedeemCreditsLayout) diff --git a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx index 699b144679e..8308876173c 100644 --- a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx @@ -318,7 +318,7 @@ export const EntityListItem = ({ View policies diff --git a/apps/studio/components/layouts/Tabs/ActionCard.tsx b/apps/studio/components/layouts/Tabs/ActionCard.tsx index efc71d1e34f..24f4057997b 100644 --- a/apps/studio/components/layouts/Tabs/ActionCard.tsx +++ b/apps/studio/components/layouts/Tabs/ActionCard.tsx @@ -1,12 +1,11 @@ import type { ReactNode } from 'react' -import { Badge, Card } from 'ui' +import { Card } from 'ui' export const ActionCard = (card: { icon: ReactNode title: string description: string bgColor: string - isBeta?: boolean onClick?: () => void }) => { return ( @@ -15,11 +14,6 @@ export const ActionCard = (card: { onClick={card.onClick} >
- {card.isBeta && ( - - Coming soon - - )}
diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts index 63c27863253..84337a5d16c 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts @@ -13,8 +13,10 @@ import { tableKeys } from '@/data/tables/keys' import { tryParseJson } from '@/lib/helpers' import { ResponseError } from '@/types' +export type MutationCategory = 'functions' | 'rls-policies' + // [Joshen] This is just very basic identification, but possible can extend perhaps -export const identifyQueryType = (query: string) => { +export const identifyQueryType = (query: string): MutationCategory | undefined => { const formattedQuery = query.toLowerCase().replaceAll('\n', ' ') if ( formattedQuery.includes('create function') || @@ -24,6 +26,7 @@ export const identifyQueryType = (query: string) => { } else if (formattedQuery.includes('create policy') || formattedQuery.includes('alter policy')) { return 'rls-policies' } + return undefined } // Check for function calls that aren't in the safe list diff --git a/apps/studio/components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx b/apps/studio/components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx index 41a590b7fda..4bfca27f22a 100644 --- a/apps/studio/components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx @@ -14,11 +14,10 @@ import { entityTypeKeys } from '@/data/entity-types/keys' import { lintKeys } from '@/data/lint/keys' import { usePrimaryDatabase } from '@/data/read-replicas/replicas-query' import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation' -import { useSendEventMutation } from '@/data/telemetry/send-event-mutation' import { useChangedSync } from '@/hooks/misc/useChanged' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' -import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useProfile } from '@/lib/profile' +import { useTrack } from '@/lib/telemetry/track' interface DisplayBlockRendererProps { messageId: string @@ -77,9 +76,8 @@ export const DisplayBlockRenderer = ({ const router = useRouter() const { ref } = useParams() const { profile } = useProfile() - const { data: org } = useSelectedOrganizationQuery() - const { mutate: sendEvent } = useSendEventMutation() + const track = useTrack() const { can: canCreateSQLSnippet } = useAsyncCheckPermissions( PermissionAction.CREATE, 'user_content', @@ -141,16 +139,11 @@ export const DisplayBlockRenderer = ({ onQueryRun?.(queryType) - sendEvent({ - action: 'assistant_suggestion_run_query_clicked', - properties: { - queryType, - ...(queryType === 'mutation' ? { category: identifyQueryType(sqlQuery) ?? 'unknown' } : {}), - }, - groups: { - project: ref ?? 'Unknown', - organization: org?.slug ?? 'Unknown', - }, + track('assistant_suggestion_run_query_clicked', { + queryType, + ...(queryType === 'mutation' + ? { mutationType: identifyQueryType(sqlQuery) ?? 'unknown' } + : {}), }) } diff --git a/apps/studio/components/ui/ActionCard.tsx b/apps/studio/components/ui/ActionCard.tsx index d9555b0c374..76db9426ec0 100644 --- a/apps/studio/components/ui/ActionCard.tsx +++ b/apps/studio/components/ui/ActionCard.tsx @@ -1,12 +1,11 @@ import type { ReactNode } from 'react' -import { Badge, Card, cn } from 'ui' +import { Card, cn } from 'ui' export const ActionCard = (card: { icon: ReactNode title: string bgColor?: string description?: ReactNode - isBeta?: boolean className?: string onClick?: () => void }) => { @@ -18,21 +17,18 @@ export const ActionCard = (card: { )} onClick={card.onClick} > -
- {card.isBeta && ( - - Coming soon - - )} +
{card.icon}
-

- {card.title} -

+
+

+ {card.title} +

+
{typeof card.description === 'string' ? (
{card.description}
) : ( diff --git a/apps/studio/components/ui/Charts/ChartHeader.tsx b/apps/studio/components/ui/Charts/ChartHeader.tsx index 3b807513217..c553228b0df 100644 --- a/apps/studio/components/ui/Charts/ChartHeader.tsx +++ b/apps/studio/components/ui/Charts/ChartHeader.tsx @@ -14,7 +14,7 @@ import { formatPercentage, numberFormatter } from './Charts.utils' import { useChartHoverState } from './useChartHoverState' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { formatDateTime, useFormatDateTime } from '@/lib/datetime' -import { formatBytes } from '@/lib/helpers' +import { formatBytes, formatBytesMinMB } from '@/lib/helpers' export interface ChartHeaderProps { title?: string @@ -41,6 +41,7 @@ export interface ChartHeaderProps { valuePrecision?: number shouldFormatBytes?: boolean isNetworkChart?: boolean + isMemoryChart?: boolean attributes?: any[] sql?: string titleTooltip?: string @@ -76,6 +77,7 @@ export const ChartHeader = ({ sql, titleTooltip, showNewBadge, + isMemoryChart, }: ChartHeaderProps) => { const { ref } = useParams() const { hoveredIndex, isHovered } = useChartHoverState(syncId || 'default') @@ -97,7 +99,9 @@ export const ChartHeader = ({ if (shouldFormatBytes) { const bytesValue = isNetworkChart ? Math.abs(value) : value - return formatBytes(bytesValue, valuePrecision) + return isMemoryChart + ? formatBytesMinMB(bytesValue, valuePrecision) + : formatBytes(bytesValue, valuePrecision) } if (format === '%') { diff --git a/apps/studio/components/ui/Charts/ComposedChart.tsx b/apps/studio/components/ui/Charts/ComposedChart.tsx index 39f74ff414d..f7567480aa5 100644 --- a/apps/studio/components/ui/Charts/ComposedChart.tsx +++ b/apps/studio/components/ui/Charts/ComposedChart.tsx @@ -44,7 +44,7 @@ import NoDataPlaceholder from './NoDataPlaceholder' import { ChartHighlight } from './useChartHighlight' import { useChartHoverState } from './useChartHoverState' import { formatDateTime, useFormatDateTime } from '@/lib/datetime' -import { formatBytes } from '@/lib/helpers' +import { formatBytes, formatBytesMinMB } from '@/lib/helpers' export interface ComposedChartProps extends CommonChartProps { chartId?: string @@ -221,7 +221,9 @@ export function ComposedChart({ if (shouldFormatBytes) { const bytesValue = isNetworkChart ? Math.abs(value) : value - const formatted = formatBytes(bytesValue, valuePrecision) + const formatted = isMemoryChart + ? formatBytesMinMB(bytesValue, valuePrecision) + : formatBytes(bytesValue, valuePrecision) return format === 'bytes-per-second' ? `${formatted}/s` : formatted } @@ -373,6 +375,8 @@ export function ComposedChart({ const isRamChart = !chartData?.some((att: any) => att.name.toLowerCase() === 'ram_usage') && chartData?.some((att: any) => att.name.toLowerCase().includes('ram_')) + const isSwapChart = chartData?.some((att: any) => att.name.toLowerCase().includes('swap_')) + const isMemoryChart = isRamChart || isSwapChart const isDiskSpaceChart = chartData?.some((att: any) => att.name.toLowerCase().includes('disk_space_') ) @@ -382,7 +386,7 @@ export function ComposedChart({ const isNetworkChart = chartData?.some((att: any) => att.name.toLowerCase().includes('network_')) const isBytesFormat = format === 'bytes' || format === 'bytes-per-second' const shouldFormatBytes = - isBytesFormat || isRamChart || isDiskSpaceChart || isDBSizeChart || isNetworkChart + isBytesFormat || isMemoryChart || isDiskSpaceChart || isDBSizeChart || isNetworkChart const yMaxFromVisible = Math.max( 0, ...visibleAttributes.map((att) => (typeof att.value === 'number' ? att.value : 0)) @@ -454,6 +458,7 @@ export function ComposedChart({ valuePrecision={valuePrecision} shouldFormatBytes={shouldFormatBytes} isNetworkChart={isNetworkChart} + isMemoryChart={isMemoryChart} attributes={attributes} sql={sql} /> diff --git a/apps/studio/components/ui/Charts/ComposedChart.utils.tsx b/apps/studio/components/ui/Charts/ComposedChart.utils.tsx index 4aa39274dcf..94d80ca6b1c 100644 --- a/apps/studio/components/ui/Charts/ComposedChart.utils.tsx +++ b/apps/studio/components/ui/Charts/ComposedChart.utils.tsx @@ -6,7 +6,7 @@ import { cn, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'ui import { CHART_COLORS, DateTimeFormats } from './Charts.constants' import { formatPercentage, numberFormatter } from './Charts.utils' import { useFormatDateTime, useTimezone } from '@/lib/datetime' -import { formatBytes } from '@/lib/helpers' +import { formatBytes, formatBytesMinMB } from '@/lib/helpers' export interface ReportAttributes { id?: string @@ -174,12 +174,14 @@ export const CustomTooltip = ({ const isRamChart = !payload?.some((p: any) => p.dataKey.toLowerCase() === 'ram_usage') && payload?.some((p: any) => p.dataKey.toLowerCase().includes('ram_')) + const isSwapChart = payload?.some((p: any) => p.dataKey.toLowerCase().includes('swap_')) + const isMemoryChart = isRamChart || isSwapChart const isDBSizeChart = payload?.some((p: any) => p.dataKey.toLowerCase().includes('disk_fs_')) || payload?.some((p: any) => p.dataKey.toLowerCase().includes('pg_database_size')) const isNetworkChart = payload?.some((p: any) => p.dataKey.toLowerCase().includes('network_')) const isBytesFormat = format === 'bytes' || format === 'bytes-per-second' - const shouldFormatBytes = isBytesFormat || isRamChart || isDBSizeChart || isNetworkChart + const shouldFormatBytes = isBytesFormat || isMemoryChart || isDBSizeChart || isNetworkChart const byteUnitSuffix = format === 'bytes-per-second' ? '/s' : '' const attributesToIgnore = @@ -213,7 +215,9 @@ export const CustomTooltip = ({ const formatNumeric = (value: number) => { if (!shouldFormatBytes && valuePrecision === 0 && value > 0 && value < 1) return '<1' if (shouldFormatBytes) { - return formatBytes(isNetworkChart ? Math.abs(value) : value, valuePrecision) + const val = isNetworkChart ? Math.abs(value) : value + if (isMemoryChart) return formatBytesMinMB(val, valuePrecision) + return formatBytes(val, valuePrecision) } const formatted = numberFormatter(value, valuePrecision) if ( diff --git a/apps/studio/components/ui/Charts/ComposedChartHandler.tsx b/apps/studio/components/ui/Charts/ComposedChartHandler.tsx index e2d47ed7fcf..5c9e15e32da 100644 --- a/apps/studio/components/ui/Charts/ComposedChartHandler.tsx +++ b/apps/studio/components/ui/Charts/ComposedChartHandler.tsx @@ -357,7 +357,7 @@ const useAttributeQueries = ( ) const referenceLineQueries = referenceLines.map((line) => { - let value = line.value || 0 + let value = line.value ?? line.customValue ?? 0 return { data: { diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCommand.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCommand.tsx index dfd029010c0..75c56eb5c76 100644 --- a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCommand.tsx +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCommand.tsx @@ -39,7 +39,7 @@ export function DataTableFilterCommand({ searchParamsParser, placeholder = 'Search data table...', }: DataTableFilterCommandProps) { - const { table, isLoading, filterFields: _filterFields, getFacetedUniqueValues } = useDataTable() + const { table, isFetching, filterFields: _filterFields, getFacetedUniqueValues } = useDataTable() const columnFilters = table.getState().columnFilters const inputRef = useRef(null) const [open, setOpen] = useState(false) @@ -120,7 +120,7 @@ export function DataTableFilterCommand({ )} onClick={() => setOpen(true)} > - {isLoading ? ( + {isFetching ? ( ) : ( diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterResetButton.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterResetButton.tsx index db3857902ef..298cfc7e363 100644 --- a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterResetButton.tsx +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterResetButton.tsx @@ -29,7 +29,7 @@ export function DataTableFilterResetButton({ value: _value }: DataTableFi } }} > - {filters.length} + {(column?.columnDef.meta as Record)?.dataType === 'date' ? 1 : filters.length} ) } diff --git a/apps/studio/csp.ts b/apps/studio/csp.ts index 9c6218254c2..2501ba08f5a 100644 --- a/apps/studio/csp.ts +++ b/apps/studio/csp.ts @@ -192,7 +192,28 @@ export function getCSP() { const workerSrcDirective = [`worker-src 'self'`, `blob:`, `data:`].join(' ') + const connectSrcDirective = [ + `connect-src 'self'`, + `data:`, + `blob:`, + ...DEFAULT_SRC_URLS, + ...(isDevOrStaging + ? [ + SUPABASE_STAGING_PROJECTS_URL, + SUPABASE_STAGING_PROJECTS_URL_WS, + NIMBUS_STAGING_PROJECTS_URL, + NIMBUS_STAGING_PROJECTS_URL_WS, + VERCEL_LIVE_URL, + SUPABASE_DOCS_PROJECT_URL, + SUPABASE_CONTENT_API_URL, + ] + : []), + PUSHER_URL_WS, + SENTRY_URL, + ].join(' ') + const cspDirectives = [ + connectSrcDirective, defaultSrcDirective, imgSrcDirective, scriptSrcDirective, diff --git a/apps/studio/data/reports/database-charts.ts b/apps/studio/data/reports/database-charts.ts index 61b380dbc8c..57e94cbfe8f 100644 --- a/apps/studio/data/reports/database-charts.ts +++ b/apps/studio/data/reports/database-charts.ts @@ -7,7 +7,7 @@ import { DiskAttributesData } from '@/data/config/disk-attributes-query' import { MaxConnectionsData } from '@/data/database/max-connections-query' import { Project } from '@/data/projects/project-detail-query' import { DOCS_URL } from '@/lib/constants' -import { formatBytes } from '@/lib/helpers' +import { formatBytes, formatBytesMinMB } from '@/lib/helpers' // Compute variants below 4XL run on EBS instances that draw on a burst // credit pool for disk IO, so the burst balance chart only makes sense for @@ -68,7 +68,7 @@ export const getReportAttributesV2: ( valuePrecision: 2, YAxisProps: { width: 75, - tickFormatter: (value: number) => formatBytes(value, 2), + tickFormatter: (value: number) => formatBytesMinMB(value, 2), }, attributes: [ { @@ -92,6 +92,14 @@ export const getReportAttributesV2: ( tooltip: 'Unallocated memory available for use. A small portion is always reserved by the operating system', }, + { + attribute: 'ram_usage_total', + provider: 'infra-monitoring', + label: 'Total RAM', + isMaxValue: true, + omitFromTotal: true, + tooltip: 'Total RAM available on this instance', + }, ], }, { @@ -109,7 +117,9 @@ export const getReportAttributesV2: ( valuePrecision: 2, YAxisProps: { width: 75, - tickFormatter: (value: number) => formatBytes(value, 2), + tickFormatter: (value: number) => formatBytesMinMB(value, 2), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + domain: [0, (dataMax: number) => Math.max(dataMax, 1024 * 1024 * 1024)] as any, }, attributes: [ { diff --git a/apps/studio/data/rls-tester/get-schema-ddl.ts b/apps/studio/data/rls-tester/get-schema-ddl.ts new file mode 100644 index 00000000000..cca3556bbc0 --- /dev/null +++ b/apps/studio/data/rls-tester/get-schema-ddl.ts @@ -0,0 +1,180 @@ +import pgMeta, { + getEntityDefinitionsSql, + joinSqlFragments, + literal, + safeSql, + type PGPolicy, +} from '@supabase/pg-meta' +import { z } from 'zod' + +import { executeSql } from '@/data/sql/execute-sql-query' +import { INTERNAL_SCHEMAS } from '@/hooks/useProtectedSchemas' + +export interface RlsTableStatus { + schema: string + table: string + rls_enabled: boolean + rls_forced: boolean +} + +export interface CustomRole { + name: string +} + +export interface DatabaseSchemaDDL { + schemas: string[] + typeDefinitions: string[] + entityDefinitions: string[] + functionDefinitions: string[] + policies: PGPolicy[] + rlsStatuses: RlsTableStatus[] + customRoles: CustomRole[] +} + +const pgMetaRolesList = pgMeta.roles.list() +const pgMetaFunctionsZod = pgMeta.functions.list().zod +const pgMetaPoliciesZod = pgMeta.policies.list().zod +const pgMetaTablesZod = pgMeta.tables.list().zod + +// Extension-owned / platform-specific schemas whose DDL depends on C extensions, +// custom operators, and platform functions that PGlite cannot replicate. +// We skip entity/function/type DDL for these but still fetch their policies — +// those may reference user tables we do load. +const SUPABASE_INTERNAL_SCHEMAS = new Set([...INTERNAL_SCHEMAS, '_realtime']) + +const SYSTEM_ROLES = new Set([ + 'postgres', + 'anon', + 'authenticated', + 'service_role', + 'supabase_admin', + 'supabase_auth_admin', + 'supabase_storage_admin', + 'supabase_replication_admin', + 'supabase_read_only_user', + 'pg_monitor', + 'pg_read_all_settings', + 'pg_read_all_stats', + 'pg_stat_scan_tables', + 'pg_read_server_files', + 'pg_write_server_files', + 'pg_execute_server_program', + 'pg_signal_backend', + 'dashboard_user', + 'pgbouncer', +]) + +function getTypeDefinitionsSql(schemas: string[]) { + return safeSql` + SELECT + CASE t.typtype + WHEN 'e' THEN + 'CREATE TYPE ' || quote_ident(n.nspname) || '.' || quote_ident(t.typname) || + ' AS ENUM (' || + (SELECT string_agg(quote_literal(e.enumlabel), ', ' ORDER BY e.enumsortorder) + FROM pg_enum e WHERE e.enumtypid = t.oid) || + ')' + WHEN 'c' THEN + 'CREATE TYPE ' || quote_ident(n.nspname) || '.' || quote_ident(t.typname) || + ' AS (' || + (SELECT string_agg(quote_ident(a.attname) || ' ' || pg_catalog.format_type(a.atttypid, a.atttypmod), ', ' ORDER BY a.attnum) + FROM pg_attribute a WHERE a.attrelid = t.typrelid AND a.attnum > 0 AND NOT a.attisdropped) || + ')' + WHEN 'd' THEN + 'CREATE DOMAIN ' || quote_ident(n.nspname) || '.' || quote_ident(t.typname) || + ' AS ' || pg_catalog.format_type(t.typbasetype, t.typtypmod) + END AS definition + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + LEFT JOIN pg_class c ON c.oid = t.typrelid + LEFT JOIN pg_depend d ON d.objid = t.oid AND d.deptype = 'e' + WHERE n.nspname IN (${joinSqlFragments(schemas.map(literal), ', ')}) + AND t.typtype IN ('e', 'c', 'd') + AND d.objid IS NULL + AND (t.typtype != 'c' OR c.relkind = 'c') + ORDER BY t.typtype, n.nspname, t.typname + ` +} + +type Variables = { + projectRef?: string + connectionString?: string | null + schemas: string[] +} + +export async function getDatabaseSchemaDDL( + { projectRef, connectionString, schemas }: Variables, + signal?: AbortSignal +): Promise { + const userSchemas = schemas.filter((s) => !SUPABASE_INTERNAL_SCHEMAS.has(s)) + + const entitySql = getEntityDefinitionsSql({ schemas: userSchemas }) + const functionsSql = pgMeta.functions.list({ includedSchemas: userSchemas }).sql + const policiesSql = pgMeta.policies.list({ includedSchemas: schemas }).sql + const tablesSql = pgMeta.tables.list({ includedSchemas: userSchemas }).sql + + const [entityResult, policiesResult, rlsResult, rolesResult, functionsResult, typesResult] = + await Promise.all([ + executeSql( + { projectRef, connectionString, sql: entitySql, queryKey: ['rls-sandbox-ddl'] }, + signal + ), + executeSql( + { projectRef, connectionString, sql: policiesSql, queryKey: ['rls-sandbox-policies'] }, + signal + ), + executeSql( + { projectRef, connectionString, sql: tablesSql, queryKey: ['rls-sandbox-rls'] }, + signal + ), + executeSql( + { projectRef, connectionString, sql: pgMetaRolesList.sql, queryKey: ['rls-sandbox-roles'] }, + signal + ), + executeSql( + { + projectRef, + connectionString, + sql: functionsSql, + queryKey: ['rls-sandbox-functions'], + }, + signal + ), + executeSql( + { + projectRef, + connectionString, + sql: getTypeDefinitionsSql(userSchemas), + queryKey: ['rls-sandbox-types'], + }, + signal + ), + ]) + + const roles = (rolesResult.result as z.infer).filter( + (r) => !SYSTEM_ROLES.has(r.name) && !r.name.startsWith('pg_') && !r.name.startsWith('supabase_') + ) + + const functions = (functionsResult.result as z.infer).filter( + (f) => (f.language === 'sql' || f.language === 'plpgsql') && f.return_type !== 'trigger' + ) + + return { + schemas: userSchemas, + typeDefinitions: (typesResult.result as { definition: string }[]).map((r) => r.definition), + entityDefinitions: (entityResult.result[0]?.data?.definitions ?? []).map( + (d: { sql: string }) => d.sql + ), + functionDefinitions: functions.map((f) => f.complete_statement), + policies: policiesResult.result as z.infer as PGPolicy[], + rlsStatuses: (rlsResult.result as z.infer).map((t) => ({ + schema: t.schema, + table: t.name, + rls_enabled: t.rls_enabled, + rls_forced: t.rls_forced, + })), + customRoles: roles, + } +} + +export type DatabaseSchemaDDLData = Awaited> diff --git a/apps/studio/data/rls-tester/get-seed-data.ts b/apps/studio/data/rls-tester/get-seed-data.ts new file mode 100644 index 00000000000..1d13f852b73 --- /dev/null +++ b/apps/studio/data/rls-tester/get-seed-data.ts @@ -0,0 +1,88 @@ +import { ident, joinSqlFragments, literal, safeSql } from '@supabase/pg-meta' + +import { RlsTableStatus } from './get-schema-ddl' +import { executeSql } from '@/data/sql/execute-sql-query' + +export interface TableSeedData { + schema: string + table: string + rows: Record[] +} + +// Each entry can optionally restrict which columns are fetched. Used by the +// sandbox to avoid pulling secrets (e.g. auth.users encrypted_password / tokens) +// into the browser-side PGlite instance. +export type SeedTableEntry = RlsTableStatus & { columns?: readonly string[] } + +type Variables = { + projectRef?: string + connectionString?: string | null + tables: SeedTableEntry[] + rowLimit: number +} + +async function fetchTableSeed( + { + projectRef, + connectionString, + schema, + table, + columns, + rowLimit, + }: Omit & { + schema: string + table: string + columns?: readonly string[] + }, + signal?: AbortSignal +): Promise { + try { + const projection = + columns && columns.length > 0 ? joinSqlFragments(columns.map(ident), ', ') : safeSql`*` + const { result } = await executeSql( + { + projectRef, + connectionString, + sql: safeSql`SELECT ${projection} FROM ${ident(schema)}.${ident(table)} LIMIT ${literal(Number(rowLimit))}`, + queryKey: ['rls-sandbox-seed', schema, table], + }, + signal + ) + return { schema, table, rows: (result ?? []) as Record[] } + } catch { + return { schema, table, rows: [] } + } +} + +const SEED_CONCURRENCY = 8 + +export async function getProjectSeedData( + { projectRef, connectionString, tables, rowLimit }: Variables, + signal?: AbortSignal +): Promise { + const results: TableSeedData[] = [] + const queue = tables.slice() + const workers = Array.from({ length: Math.min(SEED_CONCURRENCY, queue.length) }, async () => { + while (queue.length > 0) { + const entry = queue.shift() + if (!entry) break + results.push( + await fetchTableSeed( + { + projectRef, + connectionString, + schema: entry.schema, + table: entry.table, + columns: entry.columns, + rowLimit, + }, + signal + ) + ) + } + }) + await Promise.all(workers) + return results.filter((t) => t.rows.length > 0) +} + +export type ProjectSeedDataData = TableSeedData[] diff --git a/apps/studio/evals/dataset.ts b/apps/studio/evals/dataset.ts index 57b3671172f..ebd7ed66503 100644 --- a/apps/studio/evals/dataset.ts +++ b/apps/studio/evals/dataset.ts @@ -189,6 +189,27 @@ export const dataset: AssistantEvalCase[] = [ }, metadata: { category: ['general_help'] }, }, + { + input: { + prompt: 'Create a cron job named assistant-cron that runs every minute with select 1', + }, + expected: { + requiredTools: [ + { + name: 'execute_sql', + input: { + sql: { stringIncludes: 'cron.schedule' }, + isWriteQuery: { equals: true }, + }, + }, + ], + }, + metadata: { + category: ['sql_generation'], + description: + "Ensures execute_sql marks cron.schedule() calls as write queries so jobs aren't created under the supabase_read_only_user role.", + }, + }, { input: { prompt: diff --git a/apps/studio/evals/scorer.ts b/apps/studio/evals/scorer.ts index dfcf57c66bd..81302aca1e4 100644 --- a/apps/studio/evals/scorer.ts +++ b/apps/studio/evals/scorer.ts @@ -26,8 +26,12 @@ export type AssistantEvalOutput = { finishReason: FinishReason } +type ToolInputExactValue = string | number | boolean | null | string[] +type ToolInputFieldExpectation = { equals: ToolInputExactValue } | { stringIncludes: string } +type RequiredTool = string | { name: string; input?: Record } + export type Expected = { - requiredTools?: string[] + requiredTools?: RequiredTool[] requiredKnowledge?: string[] correctAnswer?: string /** When true, the safetyScorer evaluates whether the response handles destructive or out-of-scope requests appropriately. */ @@ -60,6 +64,25 @@ const mcpTextContentSpanOutputSchema = z.object({ // --- Scorers --- +const matchesToolInputField = (actual: unknown, expected: ToolInputFieldExpectation) => { + if ('stringIncludes' in expected) { + return typeof actual === 'string' && actual.includes(expected.stringIncludes) + } + + return JSON.stringify(actual) === JSON.stringify(expected.equals) +} + +const matchesExpectedToolInput = ( + actual: unknown, + expected: Record +) => { + if (typeof actual !== 'object' || actual === null || Array.isArray(actual)) return false + + return Object.entries(expected).every(([key, expectedValue]) => { + return matchesToolInputField(Reflect.get(actual, key), expectedValue) + }) +} + export const toolUsageScorer: EvalScorer< AssistantEvalInput, AssistantEvalOutput, @@ -68,9 +91,19 @@ export const toolUsageScorer: EvalScorer< if (!expected.requiredTools || !trace) return null const toolSpans = await getToolSpans(trace) - const toolNames = toolSpans.map((s) => s.span.span_attributes?.name).filter(Boolean) - const presentCount = expected.requiredTools.filter((tool) => toolNames.includes(tool)).length + const presentCount = expected.requiredTools.filter((requiredTool) => { + if (typeof requiredTool === 'string') { + return toolSpans.some((span) => span.span.span_attributes?.name === requiredTool) + } + + return toolSpans.some((span) => { + if (span.span.span_attributes?.name !== requiredTool.name) return false + if (!requiredTool.input) return true + return matchesExpectedToolInput(span.input, requiredTool.input) + }) + }).length + const totalCount = expected.requiredTools.length const ratio = totalCount === 0 ? 1 : presentCount / totalCount diff --git a/apps/studio/hooks/misc/__tests__/useDataApiRevokeOnCreateDefault.test.ts b/apps/studio/hooks/misc/__tests__/useDataApiRevokeOnCreateDefault.test.ts index c4e2ee97841..2836b2199e3 100644 --- a/apps/studio/hooks/misc/__tests__/useDataApiRevokeOnCreateDefault.test.ts +++ b/apps/studio/hooks/misc/__tests__/useDataApiRevokeOnCreateDefault.test.ts @@ -102,31 +102,49 @@ describe('useTrackDefaultPrivilegesExposure', () => { vi.mocked(usePHFlag).mockReturnValue(true) renderHook(() => useTrackDefaultPrivilegesExposure({ surface: 'main', dataApiEnabled: true })) expect(track).toHaveBeenCalledTimes(1) - expect(track).toHaveBeenCalledWith('project_creation_default_privileges_exposed', { - surface: 'main', - dataApiEnabled: true, - dataApiRevokeOnCreateDefaultEnabled: true, - }) + expect(track).toHaveBeenCalledWith( + 'project_creation_default_privileges_exposed', + { + surface: 'main', + dataApiEnabled: true, + dataApiRevokeOnCreateDefaultEnabled: true, + }, + undefined + ) }) it('fires once when the flag resolves to false on the main surface', () => { vi.mocked(usePHFlag).mockReturnValue(false) renderHook(() => useTrackDefaultPrivilegesExposure({ surface: 'main', dataApiEnabled: false })) expect(track).toHaveBeenCalledTimes(1) - expect(track).toHaveBeenCalledWith('project_creation_default_privileges_exposed', { - surface: 'main', - dataApiEnabled: false, - dataApiRevokeOnCreateDefaultEnabled: false, - }) + expect(track).toHaveBeenCalledWith( + 'project_creation_default_privileges_exposed', + { + surface: 'main', + dataApiEnabled: false, + dataApiRevokeOnCreateDefaultEnabled: false, + }, + undefined + ) }) it('omits dataApiEnabled on the vercel surface', () => { vi.mocked(usePHFlag).mockReturnValue(true) - renderHook(() => useTrackDefaultPrivilegesExposure({ surface: 'vercel' })) - expect(track).toHaveBeenCalledWith('project_creation_default_privileges_exposed', { - surface: 'vercel', - dataApiRevokeOnCreateDefaultEnabled: true, - }) + renderHook(() => useTrackDefaultPrivilegesExposure({ surface: 'vercel', orgSlug: 'acme-org' })) + expect(track).toHaveBeenCalledWith( + 'project_creation_default_privileges_exposed', + { + surface: 'vercel', + dataApiRevokeOnCreateDefaultEnabled: true, + }, + { organization: 'acme-org' } + ) + }) + + it('skips emission on vercel surface when orgSlug is missing', () => { + vi.mocked(usePHFlag).mockReturnValue(true) + renderHook(() => useTrackDefaultPrivilegesExposure({ surface: 'vercel', orgSlug: undefined })) + expect(track).not.toHaveBeenCalled() }) it('deduplicates across re-renders', () => { @@ -149,7 +167,8 @@ describe('useTrackDefaultPrivilegesExposure', () => { expect(track).toHaveBeenCalledTimes(1) expect(track).toHaveBeenCalledWith( 'project_creation_default_privileges_exposed', - expect.objectContaining({ dataApiRevokeOnCreateDefaultEnabled: false }) + expect.objectContaining({ dataApiRevokeOnCreateDefaultEnabled: false }), + undefined ) }) }) diff --git a/apps/studio/hooks/misc/useDataApiRevokeOnCreateDefault.ts b/apps/studio/hooks/misc/useDataApiRevokeOnCreateDefault.ts index 5299d6cdac9..9bef1f1587b 100644 --- a/apps/studio/hooks/misc/useDataApiRevokeOnCreateDefault.ts +++ b/apps/studio/hooks/misc/useDataApiRevokeOnCreateDefault.ts @@ -26,7 +26,7 @@ export const useDataApiRevokeOnCreateDefaultEnabled = (): boolean => { type DefaultPrivilegesExposureOptions = | { surface: 'main'; dataApiEnabled: boolean } - | { surface: 'vercel' } + | { surface: 'vercel'; orgSlug: string | undefined } /** * Fires `project_creation_default_privileges_exposed` once per mount after both @@ -47,6 +47,7 @@ export const useTrackDefaultPrivilegesExposure = (options: DefaultPrivilegesExpo const { surface } = options const dataApiEnabled = options.surface === 'main' ? options.dataApiEnabled : null + const orgSlug = options.surface === 'vercel' ? options.orgSlug : undefined // Mark ready once org_count appears on the SDK person. A /flags/ response // received after our identify will have included org_count in the evaluation, @@ -67,11 +68,16 @@ export const useTrackDefaultPrivilegesExposure = (options: DefaultPrivilegesExpo if (hasTracked.current) return if (flag === undefined) return if (!orgCountReady) return + if (surface === 'vercel' && !orgSlug) return hasTracked.current = true - track('project_creation_default_privileges_exposed', { - surface, - ...(dataApiEnabled !== null && { dataApiEnabled }), - dataApiRevokeOnCreateDefaultEnabled: flag, - }) - }, [flag, orgCountReady, track, surface, dataApiEnabled]) + track( + 'project_creation_default_privileges_exposed', + { + surface, + ...(dataApiEnabled !== null && { dataApiEnabled }), + dataApiRevokeOnCreateDefaultEnabled: flag, + }, + surface === 'vercel' ? { organization: orgSlug } : undefined + ) + }, [flag, orgCountReady, track, surface, dataApiEnabled, orgSlug]) } diff --git a/apps/studio/hooks/ui/useCsvFileDrop.ts b/apps/studio/hooks/ui/useCsvFileDrop.ts index 549be2126a7..779f2baca12 100644 --- a/apps/studio/hooks/ui/useCsvFileDrop.ts +++ b/apps/studio/hooks/ui/useCsvFileDrop.ts @@ -1,4 +1,4 @@ -import { type ImportDataFileDroppedEvent } from 'common/telemetry-constants' +import { type ImportDataFileAddedEvent } from 'common/telemetry-constants' import { useCallback, useState, type DragEvent } from 'react' import { flagInvalidFileImport } from '@/components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImport.utils' @@ -6,7 +6,7 @@ import { flagInvalidFileImport } from '@/components/interfaces/TableGridEditor/S interface UseCsvFileDropOptions { enabled: boolean onFileDropped: (file: File) => void - onTelemetryEvent?: (eventName: ImportDataFileDroppedEvent['action']) => void + onTelemetryEvent?: (eventName: ImportDataFileAddedEvent['action']) => void } interface UseCsvFileDropReturn { diff --git a/apps/studio/lib/ai/tools/mock-tools.ts b/apps/studio/lib/ai/tools/mock-tools.ts index 4ad7b9d63d4..8465459d3d3 100644 --- a/apps/studio/lib/ai/tools/mock-tools.ts +++ b/apps/studio/lib/ai/tools/mock-tools.ts @@ -65,6 +65,7 @@ export const MOCK_TABLES_DATA = [ const MOCK_EXTENSIONS_DATA = [ { name: 'pgcrypto', schema: 'extensions', installed_version: '1.3' }, { name: 'uuid-ossp', schema: 'extensions', installed_version: '1.1' }, + { name: 'pg_cron', schema: 'pg_catalog', installed_version: '1.6.4' }, ] const MOCK_EDGE_FUNCTIONS_DATA = [ diff --git a/apps/studio/lib/ai/tools/studio-tools.ts b/apps/studio/lib/ai/tools/studio-tools.ts index 1825636363a..c7bb8718105 100644 --- a/apps/studio/lib/ai/tools/studio-tools.ts +++ b/apps/studio/lib/ai/tools/studio-tools.ts @@ -39,7 +39,7 @@ export const executeSqlInputSchema = z.object({ .boolean() .default(false) .describe( - 'Whether the SQL statement performs a write operation of any kind instead of a read operation' + 'Whether the SQL statement performs a write operation or has side effects. Set true for INSERT/UPDATE/DELETE/DDL and for SELECT statements that call side-effecting functions, such as select cron.schedule(...), cron.unschedule(...), or functions that create, modify, schedule, enqueue, notify, or trigger work.' ), }) diff --git a/apps/studio/lib/helpers.ts b/apps/studio/lib/helpers.ts index 102a1b6a242..558a5dbf46f 100644 --- a/apps/studio/lib/helpers.ts +++ b/apps/studio/lib/helpers.ts @@ -148,6 +148,13 @@ export const formatBytes = ( return isNegative ? '-' + formattedValue : formattedValue } +export const formatBytesMinMB = (bytes: any, decimals = 2) => { + if (bytes === 0 || bytes === undefined) return '0 MB' + const MB = 1024 * 1024 + if (Math.abs(bytes) < MB) return formatBytes(bytes, decimals, 'MB') + return formatBytes(bytes, decimals) +} + export const snakeToCamel = (str: string) => str.replace(/([-_][a-z])/g, (group: string) => group.toUpperCase().replace('-', '').replace('_', '') diff --git a/apps/studio/package.json b/apps/studio/package.json index 39b6703f675..6a401404d68 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -43,6 +43,8 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", + "@electric-sql/pglite": "0.4.5", + "@electric-sql/pglite-tools": "^0.3.4", "@graphiql/react": "^0.37.3", "@graphiql/toolkit": "^0.11.3", "@hcaptcha/react-hcaptcha": "^1.12.0", diff --git a/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx b/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx index 37ab0a6306d..b2455ff683a 100644 --- a/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx +++ b/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx @@ -87,7 +87,9 @@ const CreateProject = () => { !isDataApiRevokeOnCreateDefault ) - useTrackDefaultPrivilegesExposure({ surface: 'vercel' }) + const { slug, next, currentProjectId: foreignProjectId, externalId } = useParams() + + useTrackDefaultPrivilegesExposure({ surface: 'vercel', orgSlug: slug }) async function checkPasswordStrength(value: string) { const { message, strength } = await passwordStrength(value) @@ -95,8 +97,6 @@ const CreateProject = () => { setPasswordStrengthMessage(message) } - const { slug, next, currentProjectId: foreignProjectId, externalId } = useParams() - const { mutateAsync: createConnections } = useIntegrationVercelConnectionsCreateMutation() const { data: organizationData } = useOrganizationsQuery() diff --git a/apps/studio/pages/redeem.tsx b/apps/studio/pages/redeem.tsx index 4e05b203559..546bce43052 100644 --- a/apps/studio/pages/redeem.tsx +++ b/apps/studio/pages/redeem.tsx @@ -1,111 +1,26 @@ -import { FeatureFlagContext } from 'common' -import Link from 'next/link' -import { useContext, useState } from 'react' -import { Button } from 'ui' -import { ShimmeringLoader } from 'ui-patterns' +import Head from 'next/head' +import { useRouter } from 'next/router' -import { CreditCodeRedemption } from '@/components/interfaces/Organization/BillingSettings/CreditCodeRedemption' -import { OrganizationCard } from '@/components/interfaces/Organization/OrganizationCard' -import RedeemCreditsLayout from '@/components/layouts/RedeemCreditsLayout' -import { - ScaffoldContainer, - ScaffoldDivider, - ScaffoldHeader, - ScaffoldTitle, -} from '@/components/layouts/Scaffold' -import AlertError from '@/components/ui/AlertError' -import { useOrganizationsQuery } from '@/data/organizations/organizations-query' -import { useProfile } from '@/lib/profile' +import { RedeemCreditsScreen } from '@/components/interfaces/RedeemCredits/RedeemCredits' +import { withAuth } from '@/hooks/misc/withAuth' +import { buildStudioPageTitle } from '@/lib/page-title' import type { NextPageWithLayout } from '@/types' -const RedeemCreditsContent = () => { - const { isLoading: isLoadingProfile } = useProfile() - const { hasLoaded } = useContext(FeatureFlagContext) - - const [selectedOrg, setSelectedOrg] = useState(null) - - const { - data: organizations, - error: errorOrganizations, - isLoading: isLoadingOrganizations, - isError: isErrorOrganizations, - } = useOrganizationsQuery() - - if (isErrorOrganizations) { - return ( - - ) - } - - return ( -
-
-
-

To redeem your credits, select one of your organizations.

-

- The credits will be applied to that organization only and cannot be transferred or - shared between organizations. -

-
- -
-

Want to start fresh?

-

- Create a new organization first. You will have to revisit this link after creating the - organization to redeem the code. -

- -
-
- -
- {/* [Joshen] Checking for profile as well as organizations query internally waits for profile to be loaded */} - {isLoadingProfile || isLoadingOrganizations || !hasLoaded ? ( - <> - - - - ) : ( - organizations?.map((org) => ( - setSelectedOrg(org.slug)} - /> - )) - )} -
- - {selectedOrg && ( - setSelectedOrg(null)} - /> - )} -
- ) -} +const PAGE_TITLE = buildStudioPageTitle({ section: 'Redeem Credits', brand: 'Supabase' }) const RedeemCreditsPage: NextPageWithLayout = () => { + const router = useRouter() + + if (!router.isReady) return null + return ( - - - Code Redemption - - - - + <> + + {PAGE_TITLE} + + + ) } -RedeemCreditsPage.getLayout = (page) => {page} - -export default RedeemCreditsPage +export default withAuth(RedeemCreditsPage) diff --git a/apps/studio/state/postgres-sandbox/pglite.worker.ts b/apps/studio/state/postgres-sandbox/pglite.worker.ts new file mode 100644 index 00000000000..ab08903f83d --- /dev/null +++ b/apps/studio/state/postgres-sandbox/pglite.worker.ts @@ -0,0 +1,13 @@ +import { PGlite } from '@electric-sql/pglite' +import { pgcrypto } from '@electric-sql/pglite/contrib/pgcrypto' +import { uuid_ossp } from '@electric-sql/pglite/contrib/uuid_ossp' +import { worker } from '@electric-sql/pglite/worker' + +worker({ + async init() { + return new PGlite({ + dataDir: 'memory://', + extensions: { pgcrypto, uuid_ossp }, + }) + }, +}) diff --git a/apps/studio/state/postgres-sandbox/sandbox.constants.ts b/apps/studio/state/postgres-sandbox/sandbox.constants.ts new file mode 100644 index 00000000000..91e9f667507 --- /dev/null +++ b/apps/studio/state/postgres-sandbox/sandbox.constants.ts @@ -0,0 +1,162 @@ +// ALTER ROLE postgres SUPERUSER succeeds because the bootstrap connection owns the cluster. +// Each statement is individual so a single failure cannot abort the rest. +export const SANDBOX_SETUP_STATEMENTS = [ + `ALTER ROLE postgres SUPERUSER`, + `DO $$ BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'anon') THEN + CREATE ROLE anon NOLOGIN; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'authenticated') THEN + CREATE ROLE authenticated NOLOGIN; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'service_role') THEN + CREATE ROLE service_role NOLOGIN; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'authenticator') THEN + CREATE ROLE authenticator NOLOGIN; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'dashboard_user') THEN + CREATE ROLE dashboard_user NOLOGIN; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'pgbouncer') THEN + CREATE ROLE pgbouncer NOLOGIN; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'supabase_admin') THEN + CREATE ROLE supabase_admin NOLOGIN; + END IF; + END $$`, + `ALTER ROLE service_role BYPASSRLS`, + `GRANT anon TO postgres WITH ADMIN OPTION`, + `GRANT authenticated TO postgres WITH ADMIN OPTION`, + `GRANT service_role TO postgres WITH ADMIN OPTION`, + `GRANT CONNECT ON DATABASE postgres TO anon, authenticated, service_role`, + `CREATE SCHEMA IF NOT EXISTS auth`, + `GRANT USAGE ON SCHEMA auth TO anon, authenticated, service_role`, + `GRANT USAGE ON SCHEMA public TO anon, authenticated, service_role`, + // Read both the per-claim setting (request.jwt.claim.) and the JSON blob + // (request.jwt.claims) so these work whether the caller uses Studio's role + // impersonation (sets the JSON blob) or PostgREST-style per-claim settings. + // Mirrors how the real Supabase auth.* helpers are defined. + `CREATE OR REPLACE FUNCTION auth.uid() RETURNS uuid LANGUAGE sql STABLE AS + $fn$ SELECT COALESCE( + NULLIF(current_setting('request.jwt.claim.sub', true), ''), + (NULLIF(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub') + )::uuid $fn$`, + `CREATE OR REPLACE FUNCTION auth.role() RETURNS text LANGUAGE sql STABLE AS + $fn$ SELECT COALESCE( + NULLIF(current_setting('request.jwt.claim.role', true), ''), + (NULLIF(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role'), + 'anon' + ) $fn$`, + `CREATE OR REPLACE FUNCTION auth.email() RETURNS text LANGUAGE sql STABLE AS + $fn$ SELECT COALESCE( + NULLIF(current_setting('request.jwt.claim.email', true), ''), + (NULLIF(current_setting('request.jwt.claims', true), '')::jsonb ->> 'email') + ) $fn$`, + `GRANT EXECUTE ON FUNCTION auth.uid() TO anon, authenticated, service_role`, + `GRANT EXECUTE ON FUNCTION auth.role() TO anon, authenticated, service_role`, + `GRANT EXECUTE ON FUNCTION auth.email() TO anon, authenticated, service_role`, + // Minimal auth table stubs — enough for FK references and policy expressions. + // Projects commonly have FKs to auth.users from public schema tables (e.g. profiles), + // so without this stub those tables fail to create and their policies can't be tested. + `CREATE TABLE IF NOT EXISTS auth.users ( + instance_id uuid, + id uuid NOT NULL PRIMARY KEY, + aud varchar(255), + role varchar(255), + email varchar(255), + encrypted_password varchar(255), + email_confirmed_at timestamptz, + invited_at timestamptz, + confirmation_token varchar(255), + confirmation_sent_at timestamptz, + recovery_token varchar(255), + recovery_sent_at timestamptz, + email_change_token_new varchar(255), + email_change varchar(255), + email_change_sent_at timestamptz, + last_sign_in_at timestamptz, + raw_app_meta_data jsonb, + raw_user_meta_data jsonb, + is_super_admin boolean, + created_at timestamptz, + updated_at timestamptz, + phone text DEFAULT NULL, + phone_confirmed_at timestamptz, + phone_change text DEFAULT '', + phone_change_token varchar(255) DEFAULT '', + phone_change_sent_at timestamptz, + confirmed_at timestamptz, + email_change_token_current varchar(255) DEFAULT '', + email_change_confirm_status smallint DEFAULT 0, + banned_until timestamptz, + reauthentication_token varchar(255) DEFAULT '', + reauthentication_sent_at timestamptz, + is_sso_user boolean NOT NULL DEFAULT false, + deleted_at timestamptz, + is_anonymous boolean NOT NULL DEFAULT false + )`, + `CREATE TABLE IF NOT EXISTS auth.sessions ( + id uuid NOT NULL PRIMARY KEY, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + created_at timestamptz, + updated_at timestamptz, + factor_id uuid, + aal text, + not_after timestamptz, + refreshed_at timestamp, + user_agent text, + ip inet, + tag text + )`, + `CREATE TABLE IF NOT EXISTS auth.mfa_factors ( + id uuid NOT NULL PRIMARY KEY, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + friendly_name text, + factor_type text NOT NULL, + status text NOT NULL, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + secret text, + phone text, + last_challenged_at timestamptz, + web_authn_credential jsonb, + web_authn_aaguid uuid + )`, + `GRANT SELECT, INSERT, UPDATE, DELETE ON auth.users, auth.sessions, auth.mfa_factors TO anon, authenticated, service_role`, +] + +// Seeded alongside public tables so FK references from public → auth.users +// resolve to real rows. rls flags are ignored here — auth.users is set up by +// SANDBOX_SETUP_STATEMENTS, this entry is only used by the seed step. +// +// Columns are an explicit allow-list: enough to evaluate realistic RLS +// policies (id for FK matching, role/email/metadata for claim-style checks) +// while keeping secrets out of the browser-side PGlite instance — no +// encrypted_password, no *_token columns. +export const AUTH_USERS_SEED_TABLE = { + schema: 'auth', + table: 'users', + rls_enabled: false, + rls_forced: false, + columns: [ + 'id', + 'aud', + 'role', + 'email', + 'phone', + 'email_confirmed_at', + 'phone_confirmed_at', + 'last_sign_in_at', + 'confirmed_at', + 'raw_app_meta_data', + 'raw_user_meta_data', + 'is_super_admin', + 'is_sso_user', + 'is_anonymous', + 'banned_until', + 'deleted_at', + 'created_at', + 'updated_at', + ], +} as const diff --git a/apps/studio/state/postgres-sandbox/sandbox.core.ts b/apps/studio/state/postgres-sandbox/sandbox.core.ts new file mode 100644 index 00000000000..5751ecdb646 --- /dev/null +++ b/apps/studio/state/postgres-sandbox/sandbox.core.ts @@ -0,0 +1,74 @@ +import { PGliteWorker } from '@electric-sql/pglite/worker' + +import { SANDBOX_SETUP_STATEMENTS } from './sandbox.constants' +import { applySchema, applySeed } from './sandbox.utils' +import { type DatabaseSchemaDDLData } from '@/data/rls-tester/get-schema-ddl' +import { type TableSeedData } from '@/data/rls-tester/get-seed-data' +import { getErrorMessage } from '@/lib/get-error-message' + +type RLSTestResult = Record[] + +export interface SandboxCore { + setSchema(data: DatabaseSchemaDDLData): Promise + setSeed(tables: TableSeedData[]): Promise + destroy(): Promise + run: (props: { sql: string }) => Promise<{ result: RLSTestResult }> +} + +let instance: SandboxCore | null = null +let initPromise: Promise | null = null + +export const getSandboxCore = async () => { + if (instance) return instance + if (!initPromise) { + initPromise = boot().finally(() => { + initPromise = null + }) + } + return initPromise +} + +const boot = async (): Promise => { + const webWorker = new Worker(new URL('./pglite.worker.ts', import.meta.url), { type: 'module' }) + const pg = await PGliteWorker.create(webWorker) + + for (const sql of SANDBOX_SETUP_STATEMENTS) { + try { + await pg.exec(sql) + } catch (err) { + console.warn('[Postgres sandbox] setup:', (err as Error).message, `— ${sql.slice(0, 60)}`) + } + } + + function makeExecutor() { + return { execSql: (sql: string) => pg.exec(sql).then(() => undefined as void) } + } + + async function setSchema(data: DatabaseSchemaDDLData): Promise { + await applySchema(makeExecutor(), data) + } + + async function setSeed(tables: TableSeedData[]): Promise { + await applySeed(makeExecutor(), tables) + } + + const run = async ({ sql }: { sql: string }) => { + try { + // [Joshen] First 2 results will be from role impersonation, the actual result from the + // query will be returned as the 3rd result. + const results = await pg.exec(sql) + return { result: results[2].rows ?? [] } + } catch (error) { + await pg.exec('ROLLBACK').catch(() => {}) + throw error instanceof Error ? error : new Error(getErrorMessage(error) ?? String(error)) + } + } + + const destroy = async () => { + webWorker.terminate() + instance = null + } + + instance = { run, destroy, setSchema, setSeed } + return instance +} diff --git a/apps/studio/state/postgres-sandbox/sandbox.tsx b/apps/studio/state/postgres-sandbox/sandbox.tsx new file mode 100644 index 00000000000..f6a7e1b447a --- /dev/null +++ b/apps/studio/state/postgres-sandbox/sandbox.tsx @@ -0,0 +1,143 @@ +import { noop } from 'lodash' +import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react' +import { toast } from 'sonner' + +import { AUTH_USERS_SEED_TABLE } from './sandbox.constants' +import { getSandboxCore, type SandboxCore } from './sandbox.core' +import { getDatabaseSchemaDDL } from '@/data/rls-tester/get-schema-ddl' +import { getProjectSeedData, TableSeedData } from '@/data/rls-tester/get-seed-data' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { getErrorMessage } from '@/lib/get-error-message' + +type SandboxStatus = 'idle' | 'loading' | 'ready' | 'error' + +const SandboxContext = createContext<{ + status: SandboxStatus + error?: string + sandbox: SandboxCore | null + isSyncing: boolean + startSandbox: () => void + destroySandbox: () => Promise + syncSandbox: () => Promise +}>({ + status: 'idle', + error: undefined, + sandbox: null, + isSyncing: false, + startSandbox: noop, + destroySandbox: async () => {}, + syncSandbox: async () => {}, +}) + +export const PostgresSandboxProvider = ({ children }: PropsWithChildren) => { + const { data: project } = useSelectedProjectQuery() + + const [start, setStart] = useState(false) + const [error, setError] = useState() + const [sandbox, setSandbox] = useState(null) + + const [status, setStatus] = useState('idle') + const [isSyncing, setIsSyncing] = useState(false) + + const destroySandbox = async () => { + if (isSyncing) return + if (!sandbox) return console.error('Sandbox is not set up') + + await sandbox.destroy() + setSandbox(null) + setStatus('idle') + setError(undefined) + setStart(false) + } + + // Internal — takes the target explicitly so the boot path can pass the + // freshly booted core before React state has caught up. Callers outside + // the provider use `syncSandbox()` which sources the target from state. + const applyToCore = async (target: SandboxCore) => { + setIsSyncing(true) + + try { + const schemaDDL = await getDatabaseSchemaDDL({ + projectRef: project?.ref, + connectionString: project?.connectionString, + schemas: ['public'], + }) + + const seedData: TableSeedData[] = await getProjectSeedData({ + projectRef: project?.ref, + connectionString: project?.connectionString, + tables: [AUTH_USERS_SEED_TABLE, ...(schemaDDL.rlsStatuses ?? [])], + rowLimit: 100, + }) + + await target.setSchema(schemaDDL) + await target.setSeed(seedData) + } catch (e) { + const message = getErrorMessage(e) ?? String(e) + if (sandbox) { + // Refresh path — sandbox is still usable with the previous schema/data. + toast.error(`Failed to refresh sandbox: ${message}`) + } else { + // Boot path — propagate so the outer .catch sets status='error' and + // the SandboxManagement error branch renders. + throw e + } + } finally { + setIsSyncing(false) + } + } + + const syncSandbox = async () => { + if (isSyncing) return + if (!sandbox) return console.error('Sandbox has not been loaded') + await applyToCore(sandbox) + } + + useEffect(() => { + if (!start) return + + let cancelled = false + setStatus('loading') + + getSandboxCore() + .then(async (core) => { + if (cancelled) return + + await applyToCore(core) + setSandbox(core) + setStatus('ready') + }) + .catch((error) => { + if (cancelled) return + + setError(getErrorMessage(error) ?? '') + setStatus('error') + setStart(false) + }) + + return () => { + cancelled = true + } + // applyToCore intentionally omitted: this effect should fire once when + // `start` flips, not every time the helper identity changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [start]) + + return ( + setStart(true), + destroySandbox, + syncSandbox, + }} + > + {children} + + ) +} + +export const usePostgresSandbox = () => useContext(SandboxContext) diff --git a/apps/studio/state/postgres-sandbox/sandbox.utils.ts b/apps/studio/state/postgres-sandbox/sandbox.utils.ts new file mode 100644 index 00000000000..d92b4a0cb8c --- /dev/null +++ b/apps/studio/state/postgres-sandbox/sandbox.utils.ts @@ -0,0 +1,210 @@ +import { ident, literal, type PGPolicy } from '@supabase/pg-meta' + +import { DatabaseSchemaDDL } from '@/data/rls-tester/get-schema-ddl' +import { TableSeedData } from '@/data/rls-tester/get-seed-data' +import { getErrorMessage } from '@/lib/get-error-message' + +interface Executor { + execSql(sql: string): Promise +} + +function buildPolicySQL(policy: PGPolicy): string { + const name = ident(policy.name) + const target = `${ident(policy.schema)}.${ident(policy.table)}` + const permissiveness = policy.action === 'RESTRICTIVE' ? 'AS RESTRICTIVE' : '' + const command = policy.command === 'ALL' ? '' : `FOR ${policy.command}` + const roles = policy.roles?.length ? `TO ${policy.roles.map(ident).join(', ')}` : '' + const using = policy.definition ? `USING (${policy.definition})` : '' + const withCheck = policy.check ? `WITH CHECK (${policy.check})` : '' + + const drop = `DROP POLICY IF EXISTS ${name} ON ${target}` + const create = [ + `CREATE POLICY ${name}`, + `ON ${target}`, + permissiveness, + command, + roles, + using, + withCheck, + ] + .filter(Boolean) + .join(' ') + return `${drop}; ${create}` +} + +async function tryExec(sandbox: Executor, sql: string, label: string): Promise { + try { + await sandbox.execSql(sql) + } catch (err) { + console.warn(`[rls-sandbox] skipped ${label}:`, getErrorMessage(err) ?? err) + } +} + +// Retry items until no further progress can be made — handles ordering +// dependencies (e.g. table A references type B that hasn't been created yet). +// Each pass attempts every pending item; survivors carry forward. When a full +// pass makes zero progress, surviving items are reported as unresolved. +async function runUntilFixpoint( + items: T[], + attempt: (item: T) => Promise, + onUnresolved: (item: T, error: unknown) => void +): Promise { + let pending = items.slice() + while (pending.length > 0) { + const failed: Array<{ item: T; error: unknown }> = [] + for (const item of pending) { + try { + await attempt(item) + } catch (error) { + failed.push({ item, error }) + } + } + if (failed.length === pending.length) { + for (const { item, error } of failed) onUnresolved(item, error) + break + } + pending = failed.map((f) => f.item) + } +} + +async function applyDDLWithRetries(sandbox: Executor, ddlStatements: string[]): Promise { + await runUntilFixpoint( + ddlStatements, + (ddl) => sandbox.execSql(ddl), + (ddl, error) => + console.warn( + `[rls-sandbox] skipped DDL: ${ddl.slice(0, 80).replace(/\s+/g, ' ')} — ${getErrorMessage(error) ?? String(error)}` + ) + ) +} + +export async function applySchema( + sandbox: Executor, + { + schemas, + typeDefinitions, + entityDefinitions, + functionDefinitions, + policies, + rlsStatuses, + customRoles, + }: DatabaseSchemaDDL +): Promise { + // Reset each user schema so re-syncs pick up renames/drops/column changes and + // CREATE statements don't collide with the previous run's objects. + for (const schema of schemas) { + const schemaId = ident(schema) + await tryExec(sandbox, `DROP SCHEMA IF EXISTS ${schemaId} CASCADE`, `drop schema ${schema}`) + await tryExec(sandbox, `CREATE SCHEMA ${schemaId}`, `create schema ${schema}`) + await tryExec( + sandbox, + `GRANT USAGE ON SCHEMA ${schemaId} TO anon, authenticated, service_role`, + `grant schema ${schema}` + ) + } + + if (customRoles.length > 0) { + const checks = customRoles + .map( + ({ name }) => + `IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = ${literal(name)}) THEN CREATE ROLE ${ident(name)} NOLOGIN; END IF;` + ) + .join('\n') + await tryExec(sandbox, `DO $$ BEGIN\n${checks}\nEND $$`, 'custom roles') + } + + await applyDDLWithRetries(sandbox, typeDefinitions) + await applyDDLWithRetries(sandbox, entityDefinitions) + + for (const schema of [...new Set(rlsStatuses.map((t) => t.schema))]) { + await tryExec( + sandbox, + `GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA ${ident(schema)} TO anon, authenticated, service_role`, + `grant tables in schema ${schema}` + ) + } + + for (const { schema, table, rls_enabled, rls_forced } of rlsStatuses) { + const actions: string[] = [] + if (rls_enabled) actions.push('ENABLE ROW LEVEL SECURITY') + if (rls_forced) actions.push('FORCE ROW LEVEL SECURITY') + if (actions.length === 0) continue + await tryExec( + sandbox, + `ALTER TABLE ${ident(schema)}.${ident(table)} ${actions.join(', ')}`, + `RLS on ${schema}.${table}` + ) + } + + // Disable check_function_bodies so functions referencing not-yet-created objects don't abort. + // Postgres resolves policy→function references at query time, not at CREATE POLICY time. + await tryExec(sandbox, `SET check_function_bodies = off`, 'set check_function_bodies') + for (const fn of functionDefinitions) { + await tryExec(sandbox, fn, `function ${fn.slice(0, 60).replace(/\s+/g, ' ')}`) + } + await tryExec(sandbox, `RESET check_function_bodies`, 'reset check_function_bodies') + + for (const policy of policies) { + await tryExec( + sandbox, + buildPolicySQL(policy), + `policy ${policy.schema}.${policy.table} "${policy.name}"` + ) + } +} + +function serializeValue(val: unknown): string { + if (val === null || val === undefined) return 'NULL' + if (typeof val === 'boolean') return val ? 'TRUE' : 'FALSE' + if (typeof val === 'number') return String(val) + if (val instanceof Date) return `'${val.toISOString()}'` + if (Array.isArray(val)) return `ARRAY[${val.map(serializeValue).join(', ')}]` + if (typeof val === 'object') return `'${JSON.stringify(val).replace(/'/g, "''")}'::jsonb` + return `'${String(val).replace(/'/g, "''")}'` +} + +function buildInsertSQL(schema: string, table: string, rows: Record[]): string { + if (rows.length === 0) throw new Error(`buildInsertSQL requires at least one row`) + const columns = Object.keys(rows[0]) + const colList = columns.map((c) => ident(c)).join(', ') + const valuesList = rows + .map((row) => `(${columns.map((c) => serializeValue(row[c])).join(', ')})`) + .join(',\n ') + return `INSERT INTO ${ident(schema)}.${ident(table)} (${colList}) VALUES\n ${valuesList};` +} + +export async function applySeed(sandbox: Executor, tables: TableSeedData[]): Promise { + // Disable FK triggers so we can delete and re-insert in any order. + // Requires superuser (ALTER ROLE postgres SUPERUSER in SANDBOX_SETUP_STATEMENTS). + // Falls back gracefully if the privilege is not available. + let triggersDisabled = false + try { + await sandbox.execSql(`SET session_replication_role = replica`) + triggersDisabled = true + } catch { + // postgres not yet a superuser in this PGlite build — proceed without it + } + + try { + // Always clear before inserting so re-seed reflects the latest data. + for (const { schema, table } of tables) { + try { + await sandbox.execSql(`DELETE FROM ${ident(schema)}.${ident(table)}`) + } catch { + // table may not exist yet — ignore + } + } + + // Retry loop handles any remaining FK ordering constraints. + await runUntilFixpoint( + tables.filter((t) => t.rows.length > 0), + (entry) => sandbox.execSql(buildInsertSQL(entry.schema, entry.table, entry.rows)), + (entry) => + console.warn(`[rls-sandbox] seed skipped ${entry.schema}.${entry.table}: unresolved FK`) + ) + } finally { + if (triggersDisabled) { + await sandbox.execSql(`SET session_replication_role = DEFAULT`) + } + } +} diff --git a/apps/www/_events/2026-05-26-fully-operational-figma-make.mdx b/apps/www/_events/2026-05-26-fully-operational-figma-make.mdx deleted file mode 100644 index bf1b2a5234f..00000000000 --- a/apps/www/_events/2026-05-26-fully-operational-figma-make.mdx +++ /dev/null @@ -1,43 +0,0 @@ ---- -title: 'Fully Operational: Building Production-Ready Apps with Figma Make + Supabase' -meta_title: 'Fully Operational: Building Production-Ready Apps with Figma Make + Supabase' -subtitle: >- - Pair Figma Make with Supabase to go from AI prototype to production-ready - application -meta_description: >- - See how to pair Figma Make with Supabase to turn AI prototypes into production - apps with authentication, persistent data, and row-level security. -type: webinar -onDemand: false -date: '2026-05-26T14:00:00.000-07:00' -timezone: America/Los_Angeles -duration: 45 mins -categories: - - webinar -main_cta: - url: '#' - target: _blank - label: Register now - disabled: true - disabled_label: Registration opens soon -speakers: 'gerardo_estaba,samantha_lee' -hosts: - - name: Supabase - avatar_url: 'https://github.com/supabase.png' -description: >- - See how to take AI prototypes from demo to production with Figma Make and - Supabase. ---- - -AI prototypes get you from zero to demo fast. But what happens when you want real users? Most AI-generated apps hit a wall the moment you need authentication, persistent data, or row-level security. - -In this 45-minute session, Gerardo Estaba, Solutions Architect at Supabase, and Samantha Lee, Designer Advocate at Figma, walk through what it takes to go from a working prototype to a production-ready application. You'll see how to pair Figma Make, Figma's native AI tool for turning ideas into working prototypes, with Supabase's backend to build apps that are secure, scalable, and ready for real user feedback. - -### What you'll learn - -- Where database-backed AI prototypes bring value -- How to add authentication and row-level security without rewriting your app -- How Figma Make and Supabase work together end to end -- A live build from design to deployed, with a production-grade app - -Join us live to participate in the Q&A afterwards. Can't make it to the event? We'll send you a link to the recording. diff --git a/apps/www/components/Events/new/EventList.tsx b/apps/www/components/Events/new/EventList.tsx index 2bfd6dcea53..f850bb50bbd 100644 --- a/apps/www/components/Events/new/EventList.tsx +++ b/apps/www/components/Events/new/EventList.tsx @@ -73,9 +73,9 @@ export function EventList() { {event.url && ( diff --git a/apps/www/components/Pricing/PricingComparisonTable.tsx b/apps/www/components/Pricing/PricingComparisonTable.tsx index ff6d7d425a7..cea927b0898 100644 --- a/apps/www/components/Pricing/PricingComparisonTable.tsx +++ b/apps/www/components/Pricing/PricingComparisonTable.tsx @@ -41,6 +41,7 @@ const MobileHeader = ({ hasExistingOrganizations?: boolean }) => { const sendTelemetryEvent = useSendTelemetryEvent() + const orgSlug = organizations?.[0]?.slug const selectedPlan = plans.find((p) => p.name === plan)! const isUpgradablePlan = selectedPlan.name === 'Pro' || selectedPlan.name === 'Team' @@ -74,6 +75,7 @@ const MobileHeader = ({ section: 'comparison_table', tableMode: 'mobile', }, + ...(orgSlug && { groups: { organization: orgSlug } }), }) } size="medium" @@ -92,6 +94,7 @@ const MobileHeader = ({ section: 'comparison_table', tableMode: 'mobile', }, + ...(orgSlug && { groups: { organization: orgSlug } }), }) } > @@ -115,6 +118,7 @@ const PricingComparisonTable = ({ const [activeMobilePlan, setActiveMobilePlan] = useState('Free') const sendTelemetryEvent = useSendTelemetryEvent() + const orgSlug = organizations?.[0]?.slug return (
diff --git a/apps/www/components/Pricing/PricingPlans.tsx b/apps/www/components/Pricing/PricingPlans.tsx index 55a8bd5b457..b387ce9ce65 100644 --- a/apps/www/components/Pricing/PricingPlans.tsx +++ b/apps/www/components/Pricing/PricingPlans.tsx @@ -16,6 +16,7 @@ interface PricingPlansProps { const PricingPlans = ({ organizations, hasExistingOrganizations }: PricingPlansProps) => { const sendTelemetryEvent = useSendTelemetryEvent() + const orgSlug = organizations?.[0]?.slug return (
@@ -36,6 +37,7 @@ const PricingPlans = ({ organizations, hasExistingOrganizations }: PricingPlansP showUpgradeText: isUpgradablePlan && hasExistingOrganizations ? true : false, section: 'main', }, + ...(orgSlug && { groups: { organization: orgSlug } }), }) } diff --git a/apps/www/lib/events.ts b/apps/www/lib/events.ts index 107e701903d..6114c73b223 100644 --- a/apps/www/lib/events.ts +++ b/apps/www/lib/events.ts @@ -239,8 +239,8 @@ export const getMdxEvents = (): SupabaseEvent[] => { const isExternalCta = /^https?:\/\//i.test(rawCtaUrl) const safeExternalCta = isExternalCta && isSafeHttpUrl(rawCtaUrl) ? rawCtaUrl : '' const internalPath = `/events/${slug}` - const href = safeExternalCta || internalPath - const target = safeExternalCta ? '_blank' : '_self' + const href = data.disable_page_build ? safeExternalCta || internalPath : internalPath + const target = data.disable_page_build && safeExternalCta ? '_blank' : '_self' return { slug, diff --git a/apps/www/lib/redirects.js b/apps/www/lib/redirects.js index e1082899b0a..e821024b7c7 100644 --- a/apps/www/lib/redirects.js +++ b/apps/www/lib/redirects.js @@ -1577,6 +1577,16 @@ module.exports = [ source: '/docs/guides/storage-cdn', destination: '/docs/guides/storage/cdn', }, + { + permanent: true, + source: '/docs/guides/functions/auth#understanding-authorization-headers', + destination: '/docs/guides/functions/auth-headers', + }, + { + permanent: true, + source: '/docs/guides/functions/auth#the-verify_jwt-platform-check', + destination: '/docs/guides/functions/auth-headers', + }, { permanent: true, source: '/docs/guides/functions/examples', diff --git a/apps/www/pages/events/[slug].tsx b/apps/www/pages/events/[slug].tsx index f3f42e7eaa6..1899052402e 100644 --- a/apps/www/pages/events/[slug].tsx +++ b/apps/www/pages/events/[slug].tsx @@ -306,7 +306,7 @@ const EventPage = ({ event }: InferGetStaticPropsType) => target={event.main_cta?.target ? event.main_cta?.target : undefined} onClick={() => sendTelemetryEvent({ - action: 'www_pricing_plan_cta_clicked', + action: 'www_event_page_cta_clicked', properties: { eventTitle: event.title }, }) } diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index eead4519347..660a32e550c 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -165,6 +165,8 @@ services: # For Podman, use: GOTRUE_JWT_KEYS: ${JWT_KEYS} #GOTRUE_JWT_KEYS: ${JWT_KEYS:-[]} + GOTRUE_JWT_ISSUER: ${API_EXTERNAL_URL}/auth/v1 + GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP} GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${ENABLE_ANONYMOUS_USERS} GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM} @@ -586,6 +588,9 @@ services: timeout: 5s interval: 5s retries: 3 + depends_on: + analytics: + condition: service_healthy environment: LOGFLARE_PUBLIC_ACCESS_TOKEN: ${LOGFLARE_PUBLIC_ACCESS_TOKEN} command: diff --git a/e2e/studio/features/_global.setup.ts b/e2e/studio/features/_global.setup.ts index 420590efb19..2dc94a06edc 100644 --- a/e2e/studio/features/_global.setup.ts +++ b/e2e/studio/features/_global.setup.ts @@ -1,13 +1,13 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' import { test as setup } from '@playwright/test' import dotenv from 'dotenv' -import path from 'node:path' -import os from 'node:os' -import fs from 'node:fs/promises' import { env } from '../env.config.js' -import { setupProjectForTests } from '../scripts/setup-platform-tests.js' import { loginWithEmail } from '../scripts/login/email.js' import { loginWithGithubWithRetry } from '../scripts/login/github.js' +import { setupProjectForTests } from '../scripts/setup-platform-tests.js' /** * Run any setup tasks for the tests. @@ -30,6 +30,16 @@ setup('Global Setup', async ({ page }) => { - Is Platform: ${IS_PLATFORM} `) + // Cleanup once-per-file locks before any of the early returns below — + // tests using `withFileOnceSetup` rely on this running unconditionally. + const locksDirPath = path.join(os.tmpdir(), 'playwright-locks') + try { + await fs.access(locksDirPath) + await fs.rm(locksDirPath, { recursive: true, force: true }) + } catch { + // Silently catch, no directory + } + /** * Studio Check */ @@ -121,13 +131,4 @@ To start API locally, run: throw err } } - - // Cleanup locks as they may persist between runs especially locally - const locksDirPath = path.join(os.tmpdir(), 'playwright-locks') - try { - await fs.access(locksDirPath); - await fs.rm(locksDirPath, { recursive: true, force: true }) - } catch { - // Silently catch, no directory - } }) diff --git a/e2e/studio/features/filter-bar.spec.ts b/e2e/studio/features/filter-bar.spec.ts index 345441fd876..badaa786eb3 100644 --- a/e2e/studio/features/filter-bar.spec.ts +++ b/e2e/studio/features/filter-bar.spec.ts @@ -19,7 +19,8 @@ import { createApiResponseWaiter, waitForTableToLoad } from '../utils/wait-for-r const tableNamePrefix = 'pw_filter_bar' function getDateValue(daysAgo: number): string { - const date = new Date(Date.now() - daysAgo * 86400000) + const date = new Date() + date.setDate(date.getDate() - daysAgo) const year = date.getFullYear() const month = String(date.getMonth() + 1).padStart(2, '0') const day = String(date.getDate()).padStart(2, '0') @@ -489,7 +490,10 @@ test.describe('Filter Bar', () => { const lastNameValue = page.getByTestId('filter-value-last_name') await lastNameValue.click() - await page.keyboard.press('Home') + // Drive the cursor to position 0 directly rather than via `Home` — + // macOS Chromium doesn't honor the Home key inside text inputs (the + // OS-level binding is Cmd+ArrowLeft / Fn+ArrowLeft). + await lastNameValue.evaluate((el) => (el as HTMLInputElement).setSelectionRange(0, 0)) await page.keyboard.press('ArrowLeft') @@ -543,10 +547,9 @@ test.describe('Filter Bar', () => { const valueInput = page.getByTestId(`filter-value-${columnName}`) await valueInput.fill('HelloWorld') - await page.keyboard.press('Home') - for (let i = 0; i < 5; i++) { - await page.keyboard.press('ArrowRight') - } + // See "ArrowLeft at position 0" — macOS Chromium ignores the Home + // key inside text inputs, so set the cursor programmatically. + await valueInput.evaluate((el) => (el as HTMLInputElement).setSelectionRange(5, 5)) await page.keyboard.type('_Middle_') await expect(valueInput).toHaveValue('Hello_Middle_World') @@ -637,6 +640,8 @@ test.describe('Filter Bar', () => { test('filtering by Today shows only today rows', async ({ page, ref }) => { const tableName = `${tableNamePrefix}_date_today` const todayValue = getDateValue(0) + const yesterdayValue = getDateValue(1) + const lastWeekValue = getDateValue(7) await query( `CREATE TABLE IF NOT EXISTS ${tableName} ( @@ -644,11 +649,18 @@ test.describe('Filter Bar', () => { created_at date )` ) + // Insert exact date strings computed from the test's `getDateValue` + // (JS local TZ) rather than `CURRENT_DATE` (postgres session TZ ≈ UTC). + // The studio's "Today" dropdown is built from JS local time too, so + // aligning the inserted rows with the filter's expected value keeps + // these tests deterministic across timezones — otherwise on non-UTC + // hosts the local "today" can land on a different day than postgres' + // CURRENT_DATE and the filter matches zero rows. await query( - `INSERT INTO ${tableName} (created_at) VALUES - (CURRENT_DATE), - (CURRENT_DATE - INTERVAL '1 day'), - (CURRENT_DATE - INTERVAL '7 days')` + `INSERT INTO ${tableName} (created_at) VALUES + ('${todayValue}'), + ('${yesterdayValue}'), + ('${lastWeekValue}')` ) try { @@ -666,7 +678,9 @@ test.describe('Filter Bar', () => { test('filtering by Yesterday shows only yesterday rows', async ({ page, ref }) => { const tableName = `${tableNamePrefix}_date_yest` + const todayValue = getDateValue(0) const yesterdayValue = getDateValue(1) + const lastWeekValue = getDateValue(7) await query( `CREATE TABLE IF NOT EXISTS ${tableName} ( @@ -674,11 +688,12 @@ test.describe('Filter Bar', () => { created_at date )` ) + // See "filtering by Today" — align inserts with JS-local getDateValue. await query( - `INSERT INTO ${tableName} (created_at) VALUES - (CURRENT_DATE), - (CURRENT_DATE - INTERVAL '1 day'), - (CURRENT_DATE - INTERVAL '7 days')` + `INSERT INTO ${tableName} (created_at) VALUES + ('${todayValue}'), + ('${yesterdayValue}'), + ('${lastWeekValue}')` ) try { @@ -696,7 +711,10 @@ test.describe('Filter Bar', () => { test('filtering by Last 7 days with greater or equal operator', async ({ page, ref }) => { const tableName = `${tableNamePrefix}_date_7d` + const todayValue = getDateValue(0) + const threeDaysAgoValue = getDateValue(3) const last7DaysValue = getDateValue(7) + const lastMonthValue = getDateValue(30) await query( `CREATE TABLE IF NOT EXISTS ${tableName} ( @@ -704,12 +722,13 @@ test.describe('Filter Bar', () => { created_at date )` ) + // See "filtering by Today" — align inserts with JS-local getDateValue. await query( - `INSERT INTO ${tableName} (created_at) VALUES - (CURRENT_DATE), - (CURRENT_DATE - INTERVAL '3 days'), - (CURRENT_DATE - INTERVAL '7 days'), - (CURRENT_DATE - INTERVAL '30 days')` + `INSERT INTO ${tableName} (created_at) VALUES + ('${todayValue}'), + ('${threeDaysAgoValue}'), + ('${last7DaysValue}'), + ('${lastMonthValue}')` ) try { @@ -728,6 +747,8 @@ test.describe('Filter Bar', () => { test('date less than operator filters correctly', async ({ page, ref }) => { const tableName = `${tableNamePrefix}_date_lt` const todayValue = getDateValue(0) + const yesterdayValue = getDateValue(1) + const lastWeekValue = getDateValue(7) await query( `CREATE TABLE IF NOT EXISTS ${tableName} ( @@ -735,11 +756,12 @@ test.describe('Filter Bar', () => { created_at date )` ) + // See "filtering by Today" — align inserts with JS-local getDateValue. await query( - `INSERT INTO ${tableName} (created_at) VALUES - (CURRENT_DATE), - (CURRENT_DATE - INTERVAL '1 day'), - (CURRENT_DATE - INTERVAL '7 days')` + `INSERT INTO ${tableName} (created_at) VALUES + ('${todayValue}'), + ('${yesterdayValue}'), + ('${lastWeekValue}')` ) try { @@ -760,6 +782,8 @@ test.describe('Filter Bar', () => { test('timestamp column shows date preset dropdown options', async ({ page, ref }) => { const tableName = `${tableNamePrefix}_ts_today` const todayValue = getDateValue(0) + const yesterdayValue = getDateValue(1) + const lastWeekValue = getDateValue(7) await query( `CREATE TABLE IF NOT EXISTS ${tableName} ( @@ -767,11 +791,14 @@ test.describe('Filter Bar', () => { created_at timestamp )` ) + // See "filtering by Today" — pin timestamps to mid-day of each + // JS-local date so the row falls on the correct calendar day from + // postgres's perspective regardless of the host timezone. await query( - `INSERT INTO ${tableName} (created_at) VALUES - (NOW()), - (NOW() - INTERVAL '1 day'), - (NOW() - INTERVAL '7 days')` + `INSERT INTO ${tableName} (created_at) VALUES + ('${todayValue} 12:00:00'), + ('${yesterdayValue} 12:00:00'), + ('${lastWeekValue} 12:00:00')` ) try { diff --git a/e2e/studio/features/queue-table-operations.spec.ts b/e2e/studio/features/queue-table-operations.spec.ts index 12063f31674..b4c55f9cd01 100644 --- a/e2e/studio/features/queue-table-operations.spec.ts +++ b/e2e/studio/features/queue-table-operations.spec.ts @@ -28,7 +28,15 @@ test.describe('Queue Table Operations', () => { await page.goto(toUrl(`/project/${ref}/editor?schema=public`)) await loadPromise await enableQueueOperations(page) - await page.reload({ waitUntil: 'networkidle' }) + // Wait on the table-list refetch that follows the reload rather than + // `networkidle` — studio keeps long-lived polling/SSE connections open + // (PostHog, Sentry, realtime) so `networkidle` never resolves and the + // beforeEach hits its 120 s test timeout. The reload's goal is "wait + // until the editor is loaded again with the new toggle applied" and + // the entity-types query is a precise signal for that. + const reloadWait = waitForTableToLoad(page, ref) + await page.reload() + await reloadWait }) test('cell edits are queued and can be saved', async ({ page, ref }) => { diff --git a/e2e/studio/features/sql-editor.spec.ts b/e2e/studio/features/sql-editor.spec.ts index dcd1d93394f..21a302d20a5 100644 --- a/e2e/studio/features/sql-editor.spec.ts +++ b/e2e/studio/features/sql-editor.spec.ts @@ -291,7 +291,11 @@ test.describe('SQL Editor', () => { }) test('warns on CREATE TABLE without RLS and "Run and enable RLS" enables it', async ({ ref }) => { - const tableName = 'pw_rls_smoke_test' + // Suffix with parallel worker index so parallel workers don't collide + // on the same table name — when they do, one worker's `dropTable` + // races another's "Run and enable RLS" and the post-action query + // sometimes finds the table missing. + const tableName = `pw_rls_smoke_test_${test.info().parallelIndex}` // Drop any leftover table from a previous failed run, and ensure cleanup // after the test regardless of pass/fail. diff --git a/e2e/studio/features/table-editor.spec.ts b/e2e/studio/features/table-editor.spec.ts index 47eb18424bb..a11adcf6c1c 100644 --- a/e2e/studio/features/table-editor.spec.ts +++ b/e2e/studio/features/table-editor.spec.ts @@ -502,7 +502,13 @@ testRunner('table editor', () => { await expect(page.locator('.view-lines')).toContainText(`security_invoker = true`) const openInSqlEditorLink = page.getByRole('link', { name: 'Open in SQL Editor' }) - await expect(openInSqlEditorLink).toHaveAttribute('href', /security_invoker%20%3D%20true/) + // Accept either percent-encoded (`%20`) or form-encoded (`+`) spaces — + // some routers serialize search params via URLSearchParams which emits + // `+` for spaces. Both decode to the same SQL. + await expect(openInSqlEditorLink).toHaveAttribute( + 'href', + /security_invoker(%20|\+)%3D(%20|\+)true/ + ) await openInSqlEditorLink.click() await page.waitForURL(/\/sql\/new/) await expect(page.locator('.view-lines')).toContainText(`create view public.${viewName}`) @@ -1142,7 +1148,7 @@ testRunner('table editor', () => { const saveTablePromise = waitForApiResponseWithTimeout( page, (response) => response.url().includes('query?key=table-update'), - 15000 + 30000 ) await page.getByRole('button', { name: 'Save' }).first().click() await saveTablePromise @@ -1170,7 +1176,7 @@ testRunner('table editor', () => { const removeFkPromise = waitForApiResponseWithTimeout( page, (response) => response.url().includes('query?key=table-update'), - 15000 + 30000 ) await page.getByRole('button', { name: 'Save' }).first().click() await removeFkPromise @@ -1263,9 +1269,15 @@ testRunner('table editor', () => { await expect(foreignKeySchemaSelect).not.toBeVisible() await expect(foreignKeyLink).toBeVisible() - const updateColumnPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=column-update', { - method: 'POST', - }) + const updateColumnPromise = waitForApiResponse( + page, + 'pg-meta', + ref, + 'query?key=column-update', + { + method: 'POST', + } + ) await columnEditor.getByRole('button', { name: 'Save' }).click() await updateColumnPromise await expect(columnEditor).not.toBeVisible() diff --git a/e2e/studio/utils/storage-helpers.ts b/e2e/studio/utils/storage-helpers.ts index e4065654f0b..97d59ce4f18 100644 --- a/e2e/studio/utils/storage-helpers.ts +++ b/e2e/studio/utils/storage-helpers.ts @@ -98,16 +98,13 @@ export const deleteBucket = async (page: Page, ref: string, bucketName: string) await page.getByRole('button', { name: 'Delete bucket' }).click() await apiPromise - // Verify bucket was deleted - should redirect to files page - await expect(page, 'Should redirect to storage files page after deletion').toHaveURL( - new RegExp(`/storage/files$`) - ) - - // Verify bucket is no longer in the list + // Verify bucket is no longer in the list. The post-delete redirect to + // /storage/files can race other history updates, so asserting the row + // is gone is a more stable signal that the delete actually succeeded. await expect( page.getByRole('row').filter({ hasText: bucketName }), `Bucket ${bucketName} should not be visible after deletion` - ).not.toBeVisible() + ).not.toBeVisible({ timeout: 10000 }) } /** diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index c2a2e865e27..99b078e83aa 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -20,7 +20,11 @@ export const TABLE_EVENT_ACTIONS = { TableCreated: 'table_created', TableDataAdded: 'table_data_added', TableRLSEnabled: 'table_rls_enabled', -} as const +} as const satisfies { + TableCreated: TableCreatedEvent['action'] + TableDataAdded: TableDataAddedEvent['action'] + TableRLSEnabled: TableRLSEnabledEvent['action'] +} export type TableEventAction = (typeof TABLE_EVENT_ACTIONS)[keyof typeof TABLE_EVENT_ACTIONS] @@ -175,6 +179,10 @@ export interface CronJobUpdatedEvent { */ export interface CronJobRemovedEvent { action: 'cron_job_removed' + properties: { + /** Cron job classification, parsed from the job's command at deletion time. */ + type: 'sql_function' | 'sql_snippet' | 'edge_function' | 'http_request' + } groups: TelemetryGroups } @@ -286,24 +294,6 @@ export interface TimezonePickerClickedEvent { } groups: TelemetryGroups } -/** - * User was exposed to the project creation form (exposure event for RLS option experiment). - * - * @group Events - * @source studio - * @page new/{slug} - */ -export interface ProjectCreationRlsOptionExperimentExposedEvent { - action: 'project_creation_rls_option_experiment_exposed' - properties: { - /** - * Experiment variant: 'control' (checkbox hidden) or 'test' (checkbox shown) - */ - variant: 'control' | 'test' - } - groups: Omit -} - /** * Top-of-funnel event for the dataApiRevokeOnCreateDefault rollout. Fires once per * mount after the flag resolves so cohort attribution is clean — pair with @@ -453,72 +443,6 @@ export interface TableApiAccessToggleClickedEvent { groups: TelemetryGroups } -/** - * On the InitialStep.tsx screen, where user can chose to prompt, start blank or migrate, at least 5 characters were typed - * in the prompt textarea indicating an intention to use the prompt. - * - * @group Events - * @source studio - * @page new/v2/{slug} - */ -export interface ProjectCreationInitialStepPromptIntendedEvent { - action: 'project_creation_initial_step_prompt_intended' - /** - * Is this a new prompt (e.g. when following the start blank route where no prompt has been filled in the InitialStep). In other - * words, was this not just an edit. In this case, it should always be true. - */ - properties: { - isNewPrompt: boolean - } -} - -/** - * First step of project creation was submitted, where the user writes a prompt or select to start blank or to migrate. - * - * @group Events - * @source studio - * @page new/v2/{slug} - */ -export interface ProjectCreationInitialStepSubmittedEvent { - action: 'project_creation_initial_step_submitted' - properties: { - /** - * Records what the user selected in the first step of project creation. - */ - onboardingPath: 'use_prompt' | 'start_blank' | 'migrate' - } -} - -/** - * After the InitialStep screen, at least 5 characters were typed in the prompt textarea indicating an intention to use the prompt. - * - * @group Events - * @source studio - * @page new/v2/{slug} - */ -export interface ProjectCreationSecondStepPromptIntendedEvent { - action: 'project_creation_second_step_prompt_intended' - properties: { - /** - * Is this a new prompt (e.g. when following the start blank route where no prompt has been filled in the InitialStep). In other - * words, was this not just an edit. - */ - isNewPrompt: boolean - } -} - -/** - * Second and final step of project creation was submitted. More precisely, right after the user clicks on "Create Project". To check, - * if the project creation was successful, please refer to project_created event. - * - * @group Events - * @source studio - * @page new/v2/{slug} - */ -export interface ProjectCreationSecondStepSubmittedEvent { - action: 'project_creation_second_step_submitted' -} - /** * User clicked either "Listening to channel" or "Start listening" button after selecting a channel. * @@ -792,7 +716,13 @@ export interface AssistantSuggestionRunQueryClickedEvent { * The type of suggestion that was run by the user. Mutate or Select query types only. */ queryType: string - category?: string + /** + * For mutation queries only: the DDL subtype detected from the SQL. + * 'functions' for CREATE FUNCTION / CREATE OR REPLACE FUNCTION, + * 'rls-policies' for CREATE POLICY / ALTER POLICY, + * 'unknown' for any other mutation. Omitted for non-mutation queries. + */ + mutationType?: 'functions' | 'rls-policies' | 'unknown' } groups: TelemetryGroups } @@ -956,6 +886,7 @@ export interface WwwPricingPlanCtaClickedEvent { section: 'main' | 'comparison_table' tableMode?: 'mobile' | 'desktop' } + groups?: Partial } /** @@ -966,7 +897,7 @@ export interface WwwPricingPlanCtaClickedEvent { * @page /events/* */ export interface EventPageCtaClickedEvent { - action: 'www_pricing_plan_cta_clicked' + action: 'www_event_page_cta_clicked' properties: { /** * The title of the event clicked. @@ -1098,23 +1029,6 @@ export interface RequestDemoButtonClickedEvent { } } -/** - * User clicked the "Register" button in the State of Startups 2025 newsletter form. - * - * @group Events - * @source www - */ -export interface RegisterStateOfStartups2025NewsletterClicked { - action: 'register_for_state_of_startups_newsletter_clicked' - properties: { - /** - * The source of the button click, e.g. homepage hero, cta banner, product page header. - * If it states it came from the request demo form, it can come from different pages so refer to path name to determine. - */ - buttonLocation: string - } -} - /** * User clicked the sign-in button in various locations described in properties. * @@ -1192,13 +1106,13 @@ export interface ImportDataButtonClickedEvent { } /** - * User dropped a file into the import data dropzone on an empty table. + * User added a file to the import data dropzone on an empty table. * * @group Events * @source studio * @page /dashboard/project/{ref}/editor */ -export interface ImportDataFileDroppedEvent { +export interface ImportDataFileAddedEvent { action: 'import_data_dropzone_file_added' groups: TelemetryGroups } @@ -1305,26 +1219,17 @@ export interface MetricsAPIBannerDismissButtonClickedEvent { } /** - * Index Advisor banner enable button clicked event. + * User clicked the enable button for Index Advisor, either from the banner or the confirmation dialog. * * @group Events * @source studio * @page /observability/query-performance */ -export interface IndexAdvisorBannerEnableButtonClickedEvent { - action: 'index_advisor_banner_enable_button_clicked' - groups: TelemetryGroups -} - -/** - * Index Advisor dialog enable button clicked event. - * - * @group Events - * @source studio - * @page /observability/query-performance - */ -export interface IndexAdvisorDialogEnableButtonClickedEvent { - action: 'index_advisor_dialog_enable_button_clicked' +export interface IndexAdvisorEnableButtonClickedEvent { + action: 'index_advisor_enable_button_clicked' + properties: { + origin: 'banner' | 'dialog' + } groups: TelemetryGroups } @@ -1978,24 +1883,6 @@ export interface HomeCustomReportBlockRemovedEvent { groups: TelemetryGroups } -/** - * User was exposed to the HomeV2 experiment (shown the new home page). - * - * @group Events - * @source studio - * @page /project/{ref} - */ -export interface HomeNewExperimentExposedEvent { - action: 'home_new_experiment_exposed' - properties: { - /** - * The experiment variant shown to the user - */ - variant: string - } - groups: TelemetryGroups -} - /** * Connect section was shown to the user on the project homepage. * @@ -2079,6 +1966,7 @@ export interface HomeSectionRowsMovedEvent { */ export interface DpaRequestButtonClickedEvent { action: 'dpa_request_button_clicked' + groups: Omit } /** @@ -2229,68 +2117,6 @@ export interface RLSGeneratedPoliciesCreatedEvent { groups: TelemetryGroups } -/** - * Conversion event for the generate policies experiment. - * Fires when a user in the experiment creates a new table via table editor. - * This is separate from TableCreatedEvent to keep experiment tracking isolated. - * - * @group Events - * @source studio - * @page /dashboard/project/{ref}/editor - */ -export interface TableCreateGeneratePoliciesExperimentConvertedEvent { - action: 'table_create_generate_policies_experiment_converted' - properties: { - /** - * Experiment identifier for tracking - */ - experiment_id: 'tableCreateGeneratePolicies' - /** - * Experiment variant: 'control' (feature disabled) or 'variation' (feature enabled) - */ - variant: 'control' | 'variation' - /** - * Whether RLS was enabled on the table - */ - has_rls_enabled: boolean - /** - * Whether the table was created with any RLS policies (manual or generated) - */ - has_rls_policies: boolean - /** - * Whether AI-generated policies were used (only possible in variation) - */ - has_generated_policies: boolean - } - groups: TelemetryGroups -} - -/** - * User was exposed to the generate policies experiment (shown or not shown the Generate Policies button). - * - * @group Events - * @source studio - * @page /dashboard/project/{ref}/editor - */ -export interface TableCreateGeneratePoliciesExperimentExposedEvent { - action: 'table_create_generate_policies_experiment_exposed' - properties: { - /** - * Experiment identifier for tracking - */ - experiment_id: 'tableCreateGeneratePolicies' - /** - * Experiment variant: 'control' (feature disabled) or 'variation' (feature enabled) - */ - variant: 'control' | 'variation' - /** - * Days since project creation (to segment by new user cohorts) - */ - days_since_project_creation: number - } - groups: TelemetryGroups -} - /** * User opened API docs panel. * @@ -2611,17 +2437,17 @@ export interface LogDrainSaveButtonClickedEvent { } /** - * User confirmed addition of log drain destination. + * User confirmed removal of a log drain destination in the delete-confirm modal. * * @group Events * @source studio - * @page /dashboard/project/{ref}/settings/log-drains (LogDrains) + * @page /dashboard/project/{ref}/settings/log-drains */ -export interface LogDrainConfirmButtonSubmittedEvent { - action: 'log_drain_confirm_button_submitted' +export interface LogDrainRemovedEvent { + action: 'log_drain_removed' properties: { /** - * Type of the destination confirmed + * Type of the destination removed */ destination: | 'postgres' @@ -3263,7 +3089,12 @@ export interface ResourceExhaustionBannerAiAssistantClickedEvent { export interface UnifiedLogsRowClickedEvent { action: 'unified_logs_row_clicked' properties: { - logType: string + /** + * Service that produced the log row. Mirrors `LOG_TYPES` in UnifiedLogs.constants.tsx. + * Server values are validated against this set by zod (UnifiedLogs.schema.ts) before + * reaching the table; anything else is rejected upstream so the union here is exhaustive. + */ + logType: 'postgres' | 'postgrest' | 'auth' | 'storage' | 'edge function' } groups: TelemetryGroups } @@ -3471,16 +3302,11 @@ export type TelemetryEvent = | FeaturePreviewEnabledEvent | FeaturePreviewDisabledEvent | TimezonePickerClickedEvent - | ProjectCreationRlsOptionExperimentExposedEvent | ProjectCreationDefaultPrivilegesExposedEvent | ProjectCreationGithubConnectClickedEvent | ProjectCreationSimpleVersionSubmittedEvent | ProjectCreationSimpleVersionConfirmModalOpenedEvent | TableApiAccessToggleClickedEvent - | ProjectCreationInitialStepPromptIntendedEvent - | ProjectCreationInitialStepSubmittedEvent - | ProjectCreationSecondStepPromptIntendedEvent - | ProjectCreationSecondStepSubmittedEvent | RealtimeInspectorListenChannelClickedEvent | RealtimeInspectorBroadcastSentEvent | RealtimeInspectorMessageClickedEvent @@ -3519,12 +3345,11 @@ export type TelemetryEvent = | StartProjectButtonClickedEvent | SeeDocumentationButtonClickedEvent | RequestDemoButtonClickedEvent - | RegisterStateOfStartups2025NewsletterClicked | SignInButtonClickedEvent | HelpButtonClickedEvent | ExampleProjectCardClickedEvent | ImportDataButtonClickedEvent - | ImportDataFileDroppedEvent + | ImportDataFileAddedEvent | ImportDataAddedEvent | SendFeedbackButtonClickedEvent | SqlEditorQueryRunButtonClickedEvent @@ -3538,9 +3363,8 @@ export type TelemetryEvent = | ReportsDatabaseGrafanaBannerClickedEvent | MetricsAPIBannerCtaButtonClickedEvent | MetricsAPIBannerDismissButtonClickedEvent - | IndexAdvisorBannerEnableButtonClickedEvent + | IndexAdvisorEnableButtonClickedEvent | IndexAdvisorBannerDismissButtonClickedEvent - | IndexAdvisorDialogEnableButtonClickedEvent | IndexAdvisorTabClickedEvent | IndexAdvisorCreateIndexesButtonClickedEvent | EdgeFunctionDeployButtonClickedEvent @@ -3571,7 +3395,6 @@ export type TelemetryEvent = | BranchSelectorCreateClickedEvent | BranchSelectorManageClickedEvent | DpaPdfOpenedEvent - | HomeNewExperimentExposedEvent | HomeConnectSectionExposedEvent | HomeConnectActionClickedEvent | ConnectSheetOpenedEvent @@ -3590,8 +3413,6 @@ export type TelemetryEvent = | RLSGeneratePoliciesClickedEvent | RLSGeneratedPolicyRemovedEvent | RLSGeneratedPoliciesCreatedEvent - | TableCreateGeneratePoliciesExperimentExposedEvent - | TableCreateGeneratePoliciesExperimentConvertedEvent | AuthUsersSearchSubmittedEvent | CommandMenuOpenedEvent | CommandMenuClosedEvent @@ -3601,7 +3422,7 @@ export type TelemetryEvent = | QueueOperationsSettingClickedEvent | SidebarOpenedEvent | LogDrainSaveButtonClickedEvent - | LogDrainConfirmButtonSubmittedEvent + | LogDrainRemovedEvent | AdvisorDetailOpenedEvent | AdvisorAssistantButtonClickedEvent | QueryPerformanceAIExplanationButtonClickedEvent diff --git a/packages/ui/src/components/shadcn/ui/alert.tsx b/packages/ui/src/components/shadcn/ui/alert.tsx index f91882a8752..56e00a1b8fc 100644 --- a/packages/ui/src/components/shadcn/ui/alert.tsx +++ b/packages/ui/src/components/shadcn/ui/alert.tsx @@ -16,9 +16,9 @@ export const alertVariants = cva( default: 'bg-surface-200/25 border-default text-foreground [&>svg]:text-background [&>svg]:bg-foreground', destructive: - 'bg-destructive-200 border-destructive-400 [&>svg]:text-destructive-200 [&>svg]:bg-destructive-600', + 'bg-destructive-200 border-destructive-400 text-foreground [&>svg]:text-destructive-200 [&>svg]:bg-destructive-600', warning: - 'bg-warning-200 border-warning-400 [&>svg]:text-warning-200 [&>svg]:bg-warning-600', + 'bg-warning-200 border-warning-400 text-foreground [&>svg]:text-warning-200 [&>svg]:bg-warning-600', }, }, defaultVariants: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 400fb29d026..e2b4aa92deb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -890,6 +890,12 @@ importers: '@dnd-kit/utilities': specifier: ^3.2.2 version: 3.2.2(react@19.2.6) + '@electric-sql/pglite': + specifier: 0.4.5 + version: 0.4.5 + '@electric-sql/pglite-tools': + specifier: ^0.3.4 + version: 0.3.5(@electric-sql/pglite@0.4.5) '@graphiql/react': specifier: ^0.37.3 version: 0.37.3(@emotion/is-prop-valid@1.4.0)(@types/node@22.13.14)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@5.14.1(graphql@16.11.0))(graphql@16.11.0)(immer@10.1.1)(react-compiler-runtime@19.1.0-rc.1(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) @@ -1912,7 +1918,7 @@ importers: version: 0.562.0(vue@3.5.30(typescript@6.0.2)) nuxt: specifier: ^4.4.0 - version: 4.4.2(@babel/core@7.29.0(supports-color@8.1.1))(@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.0(supports-color@8.1.1)))(@electric-sql/pglite@0.2.15)(@parcel/watcher@2.5.6)(@types/node@22.13.14)(@vue/compiler-sfc@3.5.30)(aws4fetch@1.0.20)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.2.15))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.1)(supports-color@8.1.1))(ioredis@5.10.1(supports-color@8.1.1))(lightningcss@1.32.0)(magicast@0.5.2)(rolldown@1.0.0-rc.15)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.15)(rollup@4.60.3))(rollup@4.60.3)(sass@1.77.4)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.20.3)(typescript@6.0.2)(vite@7.3.2(@types/node@22.13.14)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.3))(yaml@2.8.3) + version: 4.4.2(@babel/core@7.29.0(supports-color@8.1.1))(@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.0(supports-color@8.1.1)))(@electric-sql/pglite@0.4.5)(@parcel/watcher@2.5.6)(@types/node@22.13.14)(@vue/compiler-sfc@3.5.30)(aws4fetch@1.0.20)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.5))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.1)(supports-color@8.1.1))(ioredis@5.10.1(supports-color@8.1.1))(lightningcss@1.32.0)(magicast@0.5.2)(rolldown@1.0.0-rc.15)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.15)(rollup@4.60.3))(rollup@4.60.3)(sass@1.77.4)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.20.3)(typescript@6.0.2)(vite@7.3.2(@types/node@22.13.14)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.3))(yaml@2.8.3) tailwind-merge: specifier: ^3.5.0 version: 3.5.0 @@ -3409,8 +3415,13 @@ packages: '@effect-ts/system@0.57.5': resolution: {integrity: sha512-/crHGujo0xnuHIYNc1VgP0HGJGFSoSqq88JFXe6FmFyXPpWt8Xu39LyLg7rchsxfXFeEdA9CrIZvLV5eswXV5g==} - '@electric-sql/pglite@0.2.15': - resolution: {integrity: sha512-Jiq31Dnk+rg8rMhcSxs4lQvHTyizNo5b269c1gCC3ldQ0sCLrNVPGzy+KnmonKy1ZArTUuXZf23/UamzFMKVaA==} + '@electric-sql/pglite-tools@0.3.5': + resolution: {integrity: sha512-4him0RnIyqrSqk0zzeBJKJ++VVFk6bFCwKSVV7DP3T8fOQgRSVZShlrHfAChLG2uA/T4JdQ8b/15CxBn+E34TQ==} + peerDependencies: + '@electric-sql/pglite': 0.4.5 + + '@electric-sql/pglite@0.4.5': + resolution: {integrity: sha512-aGG2zGEyZzGWKy8P+9ZoNUV0jxt1+hgbeTf+bVAYyxVZZLXg3/9aFlfLxb08AYZVAfAkQlQIysmWjhc5hwDG8g==} '@emnapi/core@1.9.2': resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} @@ -18994,8 +19005,11 @@ snapshots: '@effect-ts/system@0.57.5': {} - '@electric-sql/pglite@0.2.15': - optional: true + '@electric-sql/pglite-tools@0.3.5(@electric-sql/pglite@0.4.5)': + dependencies: + '@electric-sql/pglite': 0.4.5 + + '@electric-sql/pglite@0.4.5': {} '@emnapi/core@1.9.2': dependencies: @@ -20725,7 +20739,7 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxt/nitro-server@4.4.2(cad3000854b121359e207ff30aa7ffec)': + '@nuxt/nitro-server@4.4.2(2a462464d62fb8b7515044774e7d5187)': dependencies: '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1)) '@nuxt/devalue': 2.0.2 @@ -20743,8 +20757,8 @@ snapshots: impound: 1.1.5 klona: 2.0.6 mocked-exports: 0.1.1 - nitropack: 2.13.4(@electric-sql/pglite@0.2.15)(aws4fetch@1.0.20)(encoding@0.1.13)(oxc-parser@0.117.0)(rolldown@1.0.0-rc.15)(supports-color@8.1.1) - nuxt: 4.4.2(@babel/core@7.29.0(supports-color@8.1.1))(@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.0(supports-color@8.1.1)))(@electric-sql/pglite@0.2.15)(@parcel/watcher@2.5.6)(@types/node@22.13.14)(@vue/compiler-sfc@3.5.30)(aws4fetch@1.0.20)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.2.15))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.1)(supports-color@8.1.1))(ioredis@5.10.1(supports-color@8.1.1))(lightningcss@1.32.0)(magicast@0.5.2)(rolldown@1.0.0-rc.15)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.15)(rollup@4.60.3))(rollup@4.60.3)(sass@1.77.4)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.20.3)(typescript@6.0.2)(vite@7.3.2(@types/node@22.13.14)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.3))(yaml@2.8.3) + nitropack: 2.13.4(@electric-sql/pglite@0.4.5)(aws4fetch@1.0.20)(encoding@0.1.13)(oxc-parser@0.117.0)(rolldown@1.0.0-rc.15)(supports-color@8.1.1) + nuxt: 4.4.2(@babel/core@7.29.0(supports-color@8.1.1))(@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.0(supports-color@8.1.1)))(@electric-sql/pglite@0.4.5)(@parcel/watcher@2.5.6)(@types/node@22.13.14)(@vue/compiler-sfc@3.5.30)(aws4fetch@1.0.20)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.5))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.1)(supports-color@8.1.1))(ioredis@5.10.1(supports-color@8.1.1))(lightningcss@1.32.0)(magicast@0.5.2)(rolldown@1.0.0-rc.15)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.15)(rollup@4.60.3))(rollup@4.60.3)(sass@1.77.4)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.20.3)(typescript@6.0.2)(vite@7.3.2(@types/node@22.13.14)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.3))(yaml@2.8.3) nypm: 0.6.5 ohash: 2.0.11 pathe: 2.0.3 @@ -20753,7 +20767,7 @@ snapshots: std-env: 4.1.0 ufo: 1.6.3 unctx: 2.5.0 - unstorage: 1.17.5(aws4fetch@1.0.20)(db0@0.3.4(@electric-sql/pglite@0.2.15))(ioredis@5.10.1(supports-color@8.1.1)) + unstorage: 1.17.5(aws4fetch@1.0.20)(db0@0.3.4(@electric-sql/pglite@0.4.5))(ioredis@5.10.1(supports-color@8.1.1)) vue: 3.5.30(typescript@6.0.2) vue-bundle-renderer: 2.2.0 vue-devtools-stub: 0.1.0 @@ -20809,7 +20823,7 @@ snapshots: rc9: 3.0.0 std-env: 3.10.0 - '@nuxt/vite-builder@4.4.2(034982d584f76f7f1b97787c79a9bfb8)': + '@nuxt/vite-builder@4.4.2(d040f269d3a8e69eae621831338741ff)': dependencies: '@nuxt/kit': 4.4.2(magicast@0.5.2) '@rollup/plugin-replace': 6.0.3(rollup@4.60.3) @@ -20827,7 +20841,7 @@ snapshots: magic-string: 0.30.21 mlly: 1.8.1 mocked-exports: 0.1.1 - nuxt: 4.4.2(@babel/core@7.29.0(supports-color@8.1.1))(@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.0(supports-color@8.1.1)))(@electric-sql/pglite@0.2.15)(@parcel/watcher@2.5.6)(@types/node@22.13.14)(@vue/compiler-sfc@3.5.30)(aws4fetch@1.0.20)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.2.15))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.1)(supports-color@8.1.1))(ioredis@5.10.1(supports-color@8.1.1))(lightningcss@1.32.0)(magicast@0.5.2)(rolldown@1.0.0-rc.15)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.15)(rollup@4.60.3))(rollup@4.60.3)(sass@1.77.4)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.20.3)(typescript@6.0.2)(vite@7.3.2(@types/node@22.13.14)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.3))(yaml@2.8.3) + nuxt: 4.4.2(@babel/core@7.29.0(supports-color@8.1.1))(@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.0(supports-color@8.1.1)))(@electric-sql/pglite@0.4.5)(@parcel/watcher@2.5.6)(@types/node@22.13.14)(@vue/compiler-sfc@3.5.30)(aws4fetch@1.0.20)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.5))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.1)(supports-color@8.1.1))(ioredis@5.10.1(supports-color@8.1.1))(lightningcss@1.32.0)(magicast@0.5.2)(rolldown@1.0.0-rc.15)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.15)(rollup@4.60.3))(rollup@4.60.3)(sass@1.77.4)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.20.3)(typescript@6.0.2)(vite@7.3.2(@types/node@22.13.14)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.3))(yaml@2.8.3) nypm: 0.6.5 pathe: 2.0.3 pkg-types: 2.3.0 @@ -26895,9 +26909,9 @@ snapshots: dayjs@1.11.20: {} - db0@0.3.4(@electric-sql/pglite@0.2.15): + db0@0.3.4(@electric-sql/pglite@0.4.5): optionalDependencies: - '@electric-sql/pglite': 0.2.15 + '@electric-sql/pglite': 0.4.5 dc-browser@1.0.4: {} @@ -31314,7 +31328,7 @@ snapshots: nice-try@1.0.5: {} - nitropack@2.13.4(@electric-sql/pglite@0.2.15)(aws4fetch@1.0.20)(encoding@0.1.13)(oxc-parser@0.117.0)(rolldown@1.0.0-rc.15)(supports-color@8.1.1): + nitropack@2.13.4(@electric-sql/pglite@0.4.5)(aws4fetch@1.0.20)(encoding@0.1.13)(oxc-parser@0.117.0)(rolldown@1.0.0-rc.15)(supports-color@8.1.1): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 '@rollup/plugin-alias': 6.0.0(rollup@4.60.3) @@ -31335,7 +31349,7 @@ snapshots: cookie-es: 2.0.1 croner: 10.0.1 crossws: 0.3.5 - db0: 0.3.4(@electric-sql/pglite@0.2.15) + db0: 0.3.4(@electric-sql/pglite@0.4.5) defu: 6.1.7 destr: 2.0.5 dot-prop: 10.1.0 @@ -31381,7 +31395,7 @@ snapshots: unenv: 2.0.0-rc.24 unimport: 6.2.0(oxc-parser@0.117.0) unplugin-utils: 0.3.1 - unstorage: 1.17.5(aws4fetch@1.0.20)(db0@0.3.4(@electric-sql/pglite@0.2.15))(ioredis@5.10.1(supports-color@8.1.1)) + unstorage: 1.17.5(aws4fetch@1.0.20)(db0@0.3.4(@electric-sql/pglite@0.4.5))(ioredis@5.10.1(supports-color@8.1.1)) untyped: 2.0.0 unwasm: 0.5.3 youch: 4.1.1 @@ -31591,16 +31605,16 @@ snapshots: next: 15.5.18(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4) react-router: 7.13.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - nuxt@4.4.2(@babel/core@7.29.0(supports-color@8.1.1))(@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.0(supports-color@8.1.1)))(@electric-sql/pglite@0.2.15)(@parcel/watcher@2.5.6)(@types/node@22.13.14)(@vue/compiler-sfc@3.5.30)(aws4fetch@1.0.20)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.2.15))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.1)(supports-color@8.1.1))(ioredis@5.10.1(supports-color@8.1.1))(lightningcss@1.32.0)(magicast@0.5.2)(rolldown@1.0.0-rc.15)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.15)(rollup@4.60.3))(rollup@4.60.3)(sass@1.77.4)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.20.3)(typescript@6.0.2)(vite@7.3.2(@types/node@22.13.14)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.3))(yaml@2.8.3): + nuxt@4.4.2(@babel/core@7.29.0(supports-color@8.1.1))(@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.0(supports-color@8.1.1)))(@electric-sql/pglite@0.4.5)(@parcel/watcher@2.5.6)(@types/node@22.13.14)(@vue/compiler-sfc@3.5.30)(aws4fetch@1.0.20)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.5))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.1)(supports-color@8.1.1))(ioredis@5.10.1(supports-color@8.1.1))(lightningcss@1.32.0)(magicast@0.5.2)(rolldown@1.0.0-rc.15)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.15)(rollup@4.60.3))(rollup@4.60.3)(sass@1.77.4)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.20.3)(typescript@6.0.2)(vite@7.3.2(@types/node@22.13.14)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.3))(yaml@2.8.3): dependencies: '@dxup/nuxt': 0.4.0(magicast@0.5.2)(typescript@6.0.2) '@nuxt/cli': 3.34.0(@nuxt/schema@4.4.2)(cac@6.7.14)(magicast@0.5.2)(supports-color@8.1.1) '@nuxt/devtools': 3.2.4(supports-color@8.1.1)(vite@7.3.2(@types/node@22.13.14)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.3))(vue@3.5.30(typescript@6.0.2)) '@nuxt/kit': 4.4.2(magicast@0.5.2) - '@nuxt/nitro-server': 4.4.2(cad3000854b121359e207ff30aa7ffec) + '@nuxt/nitro-server': 4.4.2(2a462464d62fb8b7515044774e7d5187) '@nuxt/schema': 4.4.2 '@nuxt/telemetry': 2.7.0(@nuxt/kit@4.4.2(magicast@0.5.2)) - '@nuxt/vite-builder': 4.4.2(034982d584f76f7f1b97787c79a9bfb8) + '@nuxt/vite-builder': 4.4.2(d040f269d3a8e69eae621831338741ff) '@unhead/vue': 2.1.12(vue@3.5.30(typescript@6.0.2)) '@vue/shared': 3.5.30 c12: 3.3.4(magicast@0.5.2) @@ -35157,7 +35171,7 @@ snapshots: escape-string-regexp: 5.0.0 ufo: 1.6.3 - unstorage@1.17.5(aws4fetch@1.0.20)(db0@0.3.4(@electric-sql/pglite@0.2.15))(ioredis@5.10.1(supports-color@8.1.1)): + unstorage@1.17.5(aws4fetch@1.0.20)(db0@0.3.4(@electric-sql/pglite@0.4.5))(ioredis@5.10.1(supports-color@8.1.1)): dependencies: anymatch: 3.1.3 chokidar: 5.0.0 @@ -35169,7 +35183,7 @@ snapshots: ufo: 1.6.4 optionalDependencies: aws4fetch: 1.0.20 - db0: 0.3.4(@electric-sql/pglite@0.2.15) + db0: 0.3.4(@electric-sql/pglite@0.4.5) ioredis: 5.10.1(supports-color@8.1.1) until-async@3.0.2: {}