diff --git a/apps/docs/content/troubleshooting/realtime-postgres-changes-troubleshooting.mdx b/apps/docs/content/troubleshooting/realtime-postgres-changes-troubleshooting.mdx new file mode 100644 index 0000000000000..2d5bcfa260845 --- /dev/null +++ b/apps/docs/content/troubleshooting/realtime-postgres-changes-troubleshooting.mdx @@ -0,0 +1,225 @@ +--- +title = "Realtime: Postgres Changes Troubleshooting" +topics = [ + "database", + "realtime", +] +keywords = [ "postgres changes", "rls", "replica identity", "subscription", "websocket" ] # any strings (topics are automatically added so no need to duplicate) +--- + +A Realtime subscription connects, a row changes in the database, and nothing arrives on the client. No error, no event — silence. This is one of the more common issues developers run into with Supabase Realtime, and the cause is almost always one of a handful of things. + +The order below reflects how often each one turns out to be the actual problem. Check RLS early, even if the subscription code looks fine — it's the single biggest source of "silently missing" events, by a wide margin. + +For a broader index of Realtime-specific issues, see the [Troubleshooting](/docs/guides/troubleshooting) directory. + +## Step 1: Is the table in the Realtime publication? + +Realtime doesn't watch every table by default. Each one has to be added to a publication called `supabase_realtime`. If it isn't, Realtime has no visibility into that table at all — no matter how the client subscription is set up. + +```sql +select * from pg_publication_tables where pubname = 'supabase_realtime'; +``` + +If the table's missing from the results: + +```sql +alter publication supabase_realtime add table your_table; +``` + +Or toggle it on from **Database → Replication** in the dashboard. + +This gets overlooked on a table created recently — creating the table and enabling Realtime on it are two separate steps: + +```sql +alter publication supabase_realtime add table messages; +``` + +## Step 2: Is RLS quietly blocking the row? + +This is the most common cause, and it's the one that wastes the most time, because nothing errors. The event doesn't show up. + +Realtime enforces RLS the same way a normal query would — as the subscribing client's role. `SUBSCRIBED` only means the WebSocket connected. It says nothing about whether that client is allowed to see the data. + +Test it directly with either options: + +Using the same credentials the client uses, try to select the row that changed: + +```js +const { data, error } = await supabase.from('messages').select('*').eq('id', theRowIdThatChanged) + +console.log({ data, error }) +``` + +Or on the dashboard, open Table Editor, and try to view the row as the subscribing user's role. + +Empty `data`? That's the answer. The policy is blocking this row for this user, and Realtime is doing exactly what it's supposed to. + +A fix might look like: + +```sql +create policy "Users can view messages in their rooms" +on messages for select +using ( + exists ( + select 1 from room_members + where room_members.room_id = messages.room_id + and room_members.user_id = auth.uid() + ) +); +``` + +One trap specific to `UPDATE` events: RLS generally has to allow both the old and new row state. If a policy only permits `status = 'active'`, and an update flips the status to `archived`, the event can fail to deliver — the row was visible a second ago, but the new state no longer passes the check. + +See [Row Level Security](/docs/guides/database/postgres/row-level-security) for the underlying model, and [why a select can return an empty data array](./why-is-my-select-returning-an-empty-data-array-and-i-have-data-in-the-table-xvOPgx), which is directly relevant to the test above. + +## Step 3: Missing fields in `payload.old`? + +If events are arriving but `payload.old` is mostly empty or `null`, that's a replica identity problem, not a delivery problem. + +By default, Postgres only sends the primary key in the "old row" for `UPDATE` and `DELETE`. If your code compares old vs. new values, that's not enough: + +```sql +alter table messages replica identity full; +``` + +If the table has Row Level Security enabled, `replica identity full` isn't enough by itself: the `old` record still contains only the primary key. There's no way around this while RLS is on — don't rely on other `payload.old` fields for a policy-protected table. + +A typical case: a `status` column moves from `pending` to `completed`, and the client checks + +```js +if (payload.old.status !== payload.new.status) { + notifyUser() +} +``` + +Without `replica identity full` (or with RLS enabled), `payload.old.status` is `undefined`, and `undefined !== 'completed'` is `true` — so the check fires `notifyUser()` on every update, not just the ones where status actually changed. + +## Step 4: Check the subscription code itself + +Once publication and RLS are ruled out, look at the subscription config. Table name, schema, filter syntax — small mismatches here are common. + +- Table name matches exactly, including case +- Schema is correct (`public`, unless you're using a custom one) +- Filter syntax is right: `room_id=eq.abc123`, not `room_id = abc123` + +```js +const channel = supabase + .channel('room-messages') + .on( + 'postgres_changes', + { + event: 'UPDATE', + schema: 'public', + table: 'messages', + filter: `room_id=eq.${roomId}`, + }, + (payload) => { + console.log('Got an update:', payload.new) + } + ) + .subscribe((status) => { + console.log('Subscription status:', status) + }) +``` + +Log the status callback. Don't assume `.subscribe()` worked. + +- `SUBSCRIBED` — connected +- `CHANNEL_ERROR` — check the error payload +- `CLOSED` — channel got shut down +- `TIMED_OUT` — connection issue; see [Realtime connections giving `TIMED_OUT` errors](./realtime-connections-timed_out-status) + +A channel stuck at `CHANNEL_ERROR` is a different problem than one that connects fine but never fires. Figure out which one you're dealing with before going further. + +## Step 5: Stale or duplicate subscriptions + +This one is almost always a React/Next.js problem. + +A component re-renders, `roomId` changes, and the old channel doesn't get cleaned up. Now you've got two subscriptions running, or one listening against a stale parameter. It doesn't always look broken — sometimes it looks like duplicate events, or events for the wrong room. + +```js +useEffect(() => { + const channel = supabase + .channel(`room:${roomId}`) + .on( + 'postgres_changes', + { event: 'UPDATE', schema: 'public', table: 'messages', filter: `room_id=eq.${roomId}` }, + (payload) => console.log(payload.new) + ) + .subscribe() + + return () => { + supabase.removeChannel(channel) + } +}, [roomId]) +``` + +That cleanup line is the part people skip. Without it, the old channel keeps running against the previous `roomId` in the background, and nothing in the UI tells you it's happening. See [Next.js 13/14 stale data when changing RLS or table data](./nextjs-1314-stale-data-when-changing-rls-or-table-data-85b8oQ) for an adjacent version of the same bug. + +## Step 6: Writing right after `SUBSCRIBED` + +There's a confirmed timing gap between the client reporting `SUBSCRIBED` and the backend's replication listener being ready to stream. A write made in that gap can get missed. + +This shows up most in automated tests, where subscribe and write happen back-to-back: + +```js +// Risky — writing immediately after subscribing +const channel = supabase.channel('test-channel').on(/* ... */).subscribe() +await supabase.from('messages').insert({ text: 'hello' }) +``` + +The reliable fix isn't a fixed delay — it's waiting for the backend to confirm the `postgres_changes` extension is listening, via the `system` message it emits: + +```js +supabase + .channel('room1') + .on('system', '*', (payload) => { + if (payload.extension === 'postgres_changes' && payload.status === 'ok') { + console.log('changes are ready', payload) // safe to write now + } + }) + .on('postgres_changes', { event: '*', schema: '*' }, (payload) => { + console.log('Change received!', payload) + }) + .subscribe() +``` + +No need to `await` anything here — react to the message inside the handler (e.g. set a flag) before triggering the write. This `system` message isn't formally documented yet. + +If you can't wire this up right now, a short fixed delay after `SUBSCRIBED` is a weaker fallback: + +```js +channel.subscribe(async (status) => { + if (status === 'SUBSCRIBED') { + await new Promise((resolve) => setTimeout(resolve, 1000)) + await supabase.from('messages').insert({ text: 'hello' }) + } +}) +``` + +## Step 7: Is Realtime enabled? + +Sometimes the answer is straightforward. Check: + +- **Project Settings → Realtime** — enabled at the project level? +- **Database → Replication** — toggle on for this specific table? + +Check this again after a project's been paused and restarted — the replication slot can need to reconnect. If the project's under heavier load than usual, also check the Realtime "Concurrent Peak Connections" quota — hitting that limit can look a lot like a broken subscription. + +## Step 8: Read the Realtime logs + +Still stuck? **Logs → Realtime** in the dashboard. Look for connection drops, replication errors, rate limiting. + +For more detail than the default logs give you, see [Debug Realtime with Logger and Log Levels](./realtime-debugging-with-logger). If the connection seems to drop intermittently rather than failing outright, check [Realtime: Handling Silent Disconnections in Background Applications](./realtime-handling-silent-disconnections-in-backgrounded-applications-592794) and [Understanding and Monitoring Realtime Heartbeats](./realtime-heartbeat-messages). + +One thing to design around regardless: Realtime doesn't guarantee every message gets delivered. Network blips and reconnects can drop an event here and there. If a missed update matters, treat the Realtime event as a signal to re-fetch state — not as the only source of truth. Then a dropped event is an inconvenience, not a bug. + +## Still nothing? + +Rule out the network layer: + +- Corporate firewall or proxy blocking WebSocket connections +- Outdated `supabase-js` version — check recent release notes + +Hopefully this guide is helpful to you in resolving these types of issues. diff --git a/apps/studio/app/api/scoped-access-token-permissions/MCPToolScopeMappings.ts b/apps/studio/app/api/scoped-access-token-permissions/MCPToolScopeMappings.ts deleted file mode 100644 index 7e2b8da4823ac..0000000000000 --- a/apps/studio/app/api/scoped-access-token-permissions/MCPToolScopeMappings.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { constants, permissions } from '@supabase/shared-types' - -import { McpMap } from '@/data/scoped-access-tokens/permission-scope-map-query' - -const { OAuthScope } = constants - -type OAuthScopeValue = (typeof OAuthScope)[keyof typeof OAuthScope] - -// Manually extracted from platform mcp controller code. Each tool maps to alternative OAuth-scope -// groups with the same semantics as ScopeGroupAlternatives: a token can call the tool when it -// holds ALL scopes of at least ONE group (OR between groups, AND within a group). Most tools -// assert a single scope; execute_sql asserts database:read OR database:write depending on the -// session's read_only mode (mcp.controller.ts), so it carries two alternatives. -const MCPToolOAuthScopeMapping: Record = { - apply_migration: [[OAuthScope.DATABASE_WRITE]], - // Computes a local confirmation hash without calling the platform — no scope gates it. - confirm_cost: [[]], - create_branch: [[OAuthScope.ENVIRONMENT_WRITE]], - create_project: [[OAuthScope.PROJECTS_WRITE]], - delete_branch: [[OAuthScope.ENVIRONMENT_WRITE]], - deploy_edge_function: [[OAuthScope.EDGE_FUNCTIONS_WRITE]], - execute_sql: [[OAuthScope.DATABASE_READ], [OAuthScope.DATABASE_WRITE]], - generate_typescript_types: [[OAuthScope.DATABASE_READ]], - get_advisors: [[OAuthScope.DATABASE_READ]], - // Calls getOrganization + listProjects to price a project, so it needs both read scopes. - // (The type=branch path returns a constant with no platform call; gating on the project - // path's scopes fails closed for branch-only pricing, which is fine for advisory display.) - get_cost: [[OAuthScope.ORGANIZATIONS_READ, OAuthScope.PROJECTS_READ]], - get_edge_function: [[OAuthScope.EDGE_FUNCTIONS_READ]], - get_logs: [[OAuthScope.ANALYTICS_READ]], - get_organization: [[OAuthScope.ORGANIZATIONS_READ]], - get_project: [[OAuthScope.PROJECTS_READ]], - get_project_url: [[OAuthScope.PROJECTS_READ]], - get_publishable_keys: [[OAuthScope.SECRETS_READ]], - get_storage_config: [[OAuthScope.STORAGE_READ]], - list_branches: [[OAuthScope.ENVIRONMENT_READ]], - list_edge_functions: [[OAuthScope.EDGE_FUNCTIONS_READ]], - // Runs through executeSql with read_only forced true. - list_extensions: [[OAuthScope.DATABASE_READ]], - list_migrations: [[OAuthScope.DATABASE_READ]], - list_organizations: [[OAuthScope.ORGANIZATIONS_READ]], - list_projects: [[OAuthScope.PROJECTS_READ]], - list_storage_buckets: [[OAuthScope.STORAGE_READ]], - // Runs through executeSql with read_only forced true. - list_tables: [[OAuthScope.DATABASE_READ]], - merge_branch: [[OAuthScope.ENVIRONMENT_WRITE]], - pause_project: [[OAuthScope.PROJECTS_WRITE]], - rebase_branch: [[OAuthScope.ENVIRONMENT_WRITE]], - reset_branch: [[OAuthScope.ENVIRONMENT_WRITE]], - restore_project: [[OAuthScope.PROJECTS_WRITE]], - // Queries the public content API — no scope gates it. - search_docs: [[]], - update_storage_config: [[OAuthScope.STORAGE_WRITE]], -} - -type ExtractIds = { - [K in keyof T]: { - [P in keyof T[K]]: T[K][P] extends { id: infer I } ? I : never - } -} -const FGA_PERMISSIONS = Object.fromEntries( - Object.entries(permissions.FgaPermissions).map(([group, permissions]) => [ - group, - Object.fromEntries(Object.entries(permissions).map(([key, { id }]) => [key, id])), - ]) -) as ExtractIds - -// Duplicated from platform (packages/api-core/src/lib/permissions/fga-permissions.ts) -// Ideally, this could be exported from @supabase/shared-types -export const legacyOauthScopeToFgaPermissionMap: Record = { - 'analytics:read': [ - FGA_PERMISSIONS.PROJECT.ANALYTICS_LOGS_READ, - FGA_PERMISSIONS.PROJECT.ANALYTICS_USAGE_READ, - ], - 'analytics:write': [], - 'analytics_config:read': [FGA_PERMISSIONS.PROJECT.ANALYTICS_CONFIG_READ], - 'analytics_config:write': [FGA_PERMISSIONS.PROJECT.ANALYTICS_CONFIG_WRITE], - 'auth:read': [FGA_PERMISSIONS.PROJECT.AUTH_CONFIG_READ], - // Note(Hieu) Auth:write scope grants access to all auth config endpoints. - // However, one endpoint requires minimum administrator role, so this oauth scope must also include the FGA PROJECT.ADMIN_WRITE permission - 'auth:write': [FGA_PERMISSIONS.PROJECT.ADMIN_WRITE, FGA_PERMISSIONS.PROJECT.AUTH_CONFIG_WRITE], - 'database:read': [ - FGA_PERMISSIONS.USER.SNIPPETS_READ, - FGA_PERMISSIONS.PROJECT.ADVISORS_READ, - FGA_PERMISSIONS.PROJECT.BACKUPS_READ, - FGA_PERMISSIONS.PROJECT.DATABASE_READ, - FGA_PERMISSIONS.PROJECT.DATABASE_CONFIG_READ, - FGA_PERMISSIONS.PROJECT.DATABASE_JIT_READ, - FGA_PERMISSIONS.PROJECT.DATABASE_MIGRATIONS_READ, - FGA_PERMISSIONS.PROJECT.DATABASE_POOLING_CONFIG_READ, - FGA_PERMISSIONS.PROJECT.DATABASE_READONLY_CONFIG_READ, - FGA_PERMISSIONS.PROJECT.DATABASE_SSL_CONFIG_READ, - FGA_PERMISSIONS.PROJECT.SNIPPETS_READ, - ], - 'database:write': [ - FGA_PERMISSIONS.PROJECT.ADMIN_WRITE, - FGA_PERMISSIONS.PROJECT.BACKUPS_WRITE, - // Note(Hieu): Include database read permission here to align with the project query endpoint. - // RLS and FGA guard this endpoint with database read first, then perform an additional check for write queries. - // The OAuth guard requires database write directly, which causes a discrepancy error if we don't include read here. - FGA_PERMISSIONS.PROJECT.DATABASE_READ, - FGA_PERMISSIONS.PROJECT.DATABASE_WRITE, - FGA_PERMISSIONS.PROJECT.DATABASE_CONFIG_WRITE, - FGA_PERMISSIONS.PROJECT.DATABASE_MIGRATIONS_WRITE, - FGA_PERMISSIONS.PROJECT.DATABASE_POOLING_CONFIG_WRITE, - FGA_PERMISSIONS.PROJECT.DATABASE_READONLY_CONFIG_WRITE, - FGA_PERMISSIONS.PROJECT.DATABASE_SSL_CONFIG_WRITE, - FGA_PERMISSIONS.PROJECT.DATABASE_WEBHOOKS_CONFIG_WRITE, - ], - 'domains:read': [ - FGA_PERMISSIONS.PROJECT.CUSTOM_DOMAIN_READ, - FGA_PERMISSIONS.PROJECT.VANITY_SUBDOMAIN_READ, - ], - 'domains:write': [ - FGA_PERMISSIONS.PROJECT.CUSTOM_DOMAIN_WRITE, - FGA_PERMISSIONS.PROJECT.VANITY_SUBDOMAIN_WRITE, - ], - 'edge_functions:read': [FGA_PERMISSIONS.PROJECT.EDGE_FUNCTIONS_READ], - 'edge_functions:write': [FGA_PERMISSIONS.PROJECT.EDGE_FUNCTIONS_WRITE], - 'environment:read': [ - FGA_PERMISSIONS.PROJECT.ACTION_RUNS_READ, - FGA_PERMISSIONS.PROJECT.BRANCHING_DEVELOPMENT_READ, - FGA_PERMISSIONS.PROJECT.BRANCHING_PRODUCTION_READ, - ], - 'environment:write': [ - FGA_PERMISSIONS.PROJECT.ACTION_RUNS_WRITE, - FGA_PERMISSIONS.PROJECT.BRANCHING_DEVELOPMENT_CREATE, - FGA_PERMISSIONS.PROJECT.BRANCHING_DEVELOPMENT_DELETE, - FGA_PERMISSIONS.PROJECT.BRANCHING_DEVELOPMENT_WRITE, - FGA_PERMISSIONS.PROJECT.BRANCHING_PRODUCTION_CREATE, - FGA_PERMISSIONS.PROJECT.BRANCHING_PRODUCTION_DELETE, - FGA_PERMISSIONS.PROJECT.BRANCHING_PRODUCTION_WRITE, - ], - 'organizations:read': [ - FGA_PERMISSIONS.USER.ORGANIZATIONS_READ, - FGA_PERMISSIONS.ORGANIZATION.ADMIN_READ, - FGA_PERMISSIONS.ORGANIZATION.MEMBERS_READ, - ], - 'organizations:write': [], - 'projects:read': [ - FGA_PERMISSIONS.USER.PROJECTS_READ, - FGA_PERMISSIONS.ORGANIZATION.PROJECTS_READ, - FGA_PERMISSIONS.PROJECT.ADMIN_READ, - FGA_PERMISSIONS.PROJECT.DATABASE_NETWORK_BANS_READ, - FGA_PERMISSIONS.PROJECT.DATABASE_NETWORK_RESTRICTIONS_READ, - ], - 'projects:write': [ - FGA_PERMISSIONS.ORGANIZATION.ADMIN_WRITE, - FGA_PERMISSIONS.ORGANIZATION.PROJECTS_CREATE, - FGA_PERMISSIONS.PROJECT.ADMIN_WRITE, - FGA_PERMISSIONS.PROJECT.DATABASE_NETWORK_BANS_WRITE, - FGA_PERMISSIONS.PROJECT.DATABASE_NETWORK_RESTRICTIONS_WRITE, - ], - 'rest:read': [FGA_PERMISSIONS.PROJECT.DATA_API_CONFIG_READ], - 'rest:write': [FGA_PERMISSIONS.PROJECT.DATA_API_CONFIG_WRITE], - 'secrets:read': [ - FGA_PERMISSIONS.PROJECT.API_GATEWAY_KEYS_READ, - FGA_PERMISSIONS.PROJECT.AUTH_SIGNING_KEYS_READ, - FGA_PERMISSIONS.PROJECT.EDGE_FUNCTIONS_SECRETS_READ, - ], - 'secrets:write': [ - FGA_PERMISSIONS.PROJECT.API_GATEWAY_KEYS_WRITE, - FGA_PERMISSIONS.PROJECT.AUTH_SIGNING_KEYS_WRITE, - FGA_PERMISSIONS.PROJECT.EDGE_FUNCTIONS_SECRETS_WRITE, - ], - 'storage:read': [ - FGA_PERMISSIONS.PROJECT.STORAGE_READ, - FGA_PERMISSIONS.PROJECT.STORAGE_CONFIG_READ, - ], - 'storage:write': [ - FGA_PERMISSIONS.PROJECT.STORAGE_WRITE, - FGA_PERMISSIONS.PROJECT.STORAGE_CONFIG_WRITE, - ], -} - -/* - * Build a map of MCP tools/FGA permissions by expanding each OAuth-scope group to the FGA - * permissions it implies: - * { - * execute_sql: [["snippets_read", "database_read", ...], ["project_admin_write", ...]] - * } - * Groups are expanded independently, preserving the OR-of-AND structure. A group is an AND, so it - * is kept only when every one of its scopes maps to at least one FGA permission — a partial - * expansion would weaken the requirement (e.g. [ORGANIZATIONS_READ, PROJECTS_READ] shrinking to - * projects_read alone). A group with any unmapped scope is dropped whole, so the tool stays gated - * rather than becoming ungated; an explicitly empty group ([]) is the deliberate ungated marker - * and is vacuously kept. - * The code is duplicated from platform until we find a better way to share those mappings - */ -export const expandOAuthScopeGroups = ( - oAuthScopeGroups: string[][], - fgaPermissionMap: Record -): string[][] => - oAuthScopeGroups - .filter((group) => group.every((oAuthScope) => (fgaPermissionMap[oAuthScope] ?? []).length > 0)) - .map((group) => group.flatMap((oAuthScope) => fgaPermissionMap[oAuthScope] ?? [])) - -export const MCPToolScopeMappings = Object.entries(MCPToolOAuthScopeMapping).reduce( - (acc, [mcpTool, oAuthScopeGroups]) => { - acc[mcpTool] = expandOAuthScopeGroups(oAuthScopeGroups, legacyOauthScopeToFgaPermissionMap) - return acc - }, - {} as McpMap -) diff --git a/apps/studio/app/api/scoped-access-token-permissions/buildAPIPermissionScopeMap.test.ts b/apps/studio/app/api/scoped-access-token-permissions/buildAPIPermissionScopeMap.test.ts index 65a5ee62fc1d6..d550f2b5683fb 100644 --- a/apps/studio/app/api/scoped-access-token-permissions/buildAPIPermissionScopeMap.test.ts +++ b/apps/studio/app/api/scoped-access-token-permissions/buildAPIPermissionScopeMap.test.ts @@ -6,7 +6,6 @@ import { buildAPIPermissionScopeMap, getScopesAndEndpointsForAPI, } from './buildAPIPermissionScopeMap' -import { expandOAuthScopeGroups, MCPToolScopeMappings } from './MCPToolScopeMappings' import { type ScopeMap } from '@/data/scoped-access-tokens/permission-scope-map-query' import { mswServer } from '@/tests/lib/msw' @@ -135,115 +134,24 @@ describe('addMCPToolsToScopes', () => { }) }) -describe('MCPToolScopeMappings', () => { - // Platform gates execute_sql on database:read OR database:write depending on the MCP session's - // read_only mode (mcp.controller.ts), so the derived requirement must be two alternatives — a - // single conjunctive group would hide the tool from read-only tokens the platform accepts. - test('execute_sql derives the database:read bundle OR the database:write bundle', () => { - expect(MCPToolScopeMappings.execute_sql).toHaveLength(2) - const [readGroup, writeGroup] = MCPToolScopeMappings.execute_sql - expect(readGroup).toContain('database_read') - expect(readGroup).not.toContain('database_write') - expect(writeGroup).toContain('database_write') - }) - - test('single-scope tools derive a single conjunctive group', () => { - expect(MCPToolScopeMappings.apply_migration).toHaveLength(1) - expect(MCPToolScopeMappings.apply_migration[0]).toContain('database_write') - }) - - test('tools without a platform scope gate stay ungated ([[]]), not disabled ([])', () => { - expect(MCPToolScopeMappings.confirm_cost).toEqual([[]]) - expect(MCPToolScopeMappings.search_docs).toEqual([[]]) - }) - - // A group is an AND: expanding only its mapped scopes would weaken the requirement (e.g. - // [organizations:read, projects:read] shrinking to projects_read alone) and report the tool - // enabled for an incomplete grant. - test('a group with any unmapped scope is dropped whole, not partially expanded', () => { - const map = { 'projects:read': ['projects_read'] } - - expect(expandOAuthScopeGroups([['organizations:read', 'projects:read']], map)).toEqual([]) - // Other alternatives and the ungated marker survive the drop untouched. - expect(expandOAuthScopeGroups([['organizations:read'], ['projects:read'], []], map)).toEqual([ - ['projects_read'], - [], - ]) - }) - - // Guards the OAuth-scope -> legacy-map join: a scope key drifting out of the legacy map must - // not inject undefined into the payload (flatMap doesn't flatten it) or silently disable a - // gated tool by dropping all its groups. - test('every derived group is non-empty strings, and only the ungated tools lack scopes', () => { - const ungated = ['confirm_cost', 'search_docs'] - for (const [tool, groups] of Object.entries(MCPToolScopeMappings)) { - expect(groups.length, `${tool} lost all its alternatives`).toBeGreaterThan(0) - for (const group of groups) { - if (!ungated.includes(tool)) - expect(group.length, `${tool} has an empty group`).toBeGreaterThan(0) - for (const scope of group) - expect(typeof scope, `${tool} leaked a non-string scope`).toBe('string') - } - } - }) - - test('get_cost requires both organization and project read bundles together', () => { - expect(MCPToolScopeMappings.get_cost).toHaveLength(1) - expect(MCPToolScopeMappings.get_cost[0]).toEqual( - expect.arrayContaining(['organizations_read', 'projects_read']) - ) - }) - - test('covers exactly the tool registry of the deployed MCP server', () => { - expect(Object.keys(MCPToolScopeMappings).sort()).toEqual([ - 'apply_migration', - 'confirm_cost', - 'create_branch', - 'create_project', - 'delete_branch', - 'deploy_edge_function', - 'execute_sql', - 'generate_typescript_types', - 'get_advisors', - 'get_cost', - 'get_edge_function', - 'get_logs', - 'get_organization', - 'get_project', - 'get_project_url', - 'get_publishable_keys', - 'get_storage_config', - 'list_branches', - 'list_edge_functions', - 'list_extensions', - 'list_migrations', - 'list_organizations', - 'list_projects', - 'list_storage_buckets', - 'list_tables', - 'merge_branch', - 'pause_project', - 'rebase_branch', - 'reset_branch', - 'restore_project', - 'search_docs', - 'update_storage_config', - ]) - }) -}) - describe('buildAPIPermissionScopeMap', () => { // vitestSetup starts mswServer with `onUnhandledRequest: 'error'` and resets handlers between - // tests, so mocking here keeps that guard instead of replacing global fetch. - const stubSpecs = (v1: Record, v2: Record) => { + // tests, so mocking here keeps that guard instead of replacing global fetch. All three live + // sources (v1 spec, v2 spec, the MCP tool-permissions endpoint) are stubbed. + const stubSources = ( + v1: Record, + v2: Record, + mcpTools: Record = { execute_sql: [['database_read']] } + ) => { mswServer.use( http.get('*/api/v1-json', () => HttpResponse.json(v1)), - http.get('*/api/v2-json', () => HttpResponse.json(v2)) + http.get('*/api/v2-json', () => HttpResponse.json(v2)), + http.get('*/platform/mcp-tools-permissions', () => HttpResponse.json(mcpTools)) ) } test('merges both specs, attaching each MCP tool to a shared scope exactly once', async () => { - stubSpecs( + stubSources( { paths: { '/v1/projects/{ref}/database/query': { @@ -274,7 +182,7 @@ describe('buildAPIPermissionScopeMap', () => { // Path items may legally carry non-operation members; the specs are fetched live, so a benign // upstream swagger change must not start 500ing this route. test('tolerates path items with non-method OpenAPI members', async () => { - stubSpecs( + stubSources( { paths: { '/v1/projects/{ref}': { @@ -293,15 +201,33 @@ describe('buildAPIPermissionScopeMap', () => { expect(Object.keys(map.endpoints)).toHaveLength(1) }) - test('returns a copy of the tool mapping so callers cannot corrupt the module singleton', async () => { - stubSpecs({ paths: {} }, { paths: {} }) + test('returns the MCP tool map fetched from the endpoint', async () => { + stubSources( + { paths: {} }, + { paths: {} }, + { + apply_migration: [['database_migrations_write']], + search_docs: [[]], + } + ) const map = await buildAPIPermissionScopeMap() - expect(map.mcp_tools).toEqual(MCPToolScopeMappings) - expect(map.mcp_tools).not.toBe(MCPToolScopeMappings) - const before = structuredClone(MCPToolScopeMappings.execute_sql) - map.mcp_tools.execute_sql.push(['tampered']) - expect(MCPToolScopeMappings.execute_sql).toEqual(before) + expect(map.mcp_tools).toEqual({ + apply_migration: [['database_migrations_write']], + search_docs: [[]], + }) + // The gated tool is indexed under its permission; the ungated one is not. + expect(map.scopes.database_migrations_write.mcp_tools).toEqual(['apply_migration']) + }) + + test('throws when the MCP tool-permissions endpoint is unavailable', async () => { + mswServer.use( + http.get('*/api/v1-json', () => HttpResponse.json({ paths: {} })), + http.get('*/api/v2-json', () => HttpResponse.json({ paths: {} })), + http.get('*/platform/mcp-tools-permissions', () => new HttpResponse(null, { status: 503 })) + ) + + await expect(buildAPIPermissionScopeMap()).rejects.toThrow() }) }) diff --git a/apps/studio/app/api/scoped-access-token-permissions/buildAPIPermissionScopeMap.ts b/apps/studio/app/api/scoped-access-token-permissions/buildAPIPermissionScopeMap.ts index b6b568e602284..95655c5884edf 100644 --- a/apps/studio/app/api/scoped-access-token-permissions/buildAPIPermissionScopeMap.ts +++ b/apps/studio/app/api/scoped-access-token-permissions/buildAPIPermissionScopeMap.ts @@ -1,9 +1,5 @@ -import { cloneDeep } from 'lodash' import z from 'zod' -// We don't have an OpenAPI that describes mcp tools security requirements so -// we have this hard coded file that must be updated when they change -import { MCPToolScopeMappings } from './MCPToolScopeMappings' import { EndpointMap, McpMap, @@ -13,32 +9,29 @@ import { import { InternalServerError } from '@/lib/api/apiHelpers' /* - * Builds the permissions/endpoint mapping by fetching the OpenAPI specs for our v1 and v2 APIs. - * The two specs are indexed together rather than merged afterwards: every v1 path starts with - * `/v1/` and every v2 path with `/v2/`, so they can't collide, and one pass de-duplicates a - * scope's endpoint list by construction. - * @throws InternalServerError when it can't fetch the OpenAPI specs + * Builds the permissions/endpoint mapping from three live sources: the v1 and v2 OpenAPI specs + * (endpoint -> FGA via `x-fga-permissions`) and the mgmt-api MCP-tool-permissions endpoint + * (tool -> FGA). The MCP map is owned by Control Plane — it's projected from the same MCP_TOOL_AUTH + * descriptor that drives enforcement — so Studio fetches it exactly like the OpenAPI spec instead of + * hand-maintaining or importing a copy. + * @throws InternalServerError when it can't fetch the specs or the MCP map */ export const buildAPIPermissionScopeMap = async (): Promise => { - const [apiV1SpecsJSON, apiV2SpecsJSON] = await Promise.all([ + const [apiV1SpecsJSON, apiV2SpecsJSON, mcpToolsJSON] = await Promise.all([ fetchAPIPermissionScope('v1'), fetchAPIPermissionScope('v2'), + fetchMcpToolPermissions(), ]) const apiV1Specs = API_SPECS_SCHEMA.parse(apiV1SpecsJSON) const apiV2Specs = API_SPECS_SCHEMA.parse(apiV2SpecsJSON) + const mcpTools = MCP_TOOLS_SCHEMA.parse(mcpToolsJSON) const { scopes, endpoints } = getScopesAndEndpointsForAPI({ paths: { ...apiV1Specs.paths, ...apiV2Specs.paths }, }) - addMCPToolsToScopes(scopes, MCPToolScopeMappings) - - return { - scopes, - endpoints, - // Deep copy so a caller mutating the response can't corrupt the module-level mapping, which - // outlives every request in a long-running server. - mcp_tools: cloneDeep(MCPToolScopeMappings), - } + addMCPToolsToScopes(scopes, mcpTools) + + return { scopes, endpoints, mcp_tools: mcpTools } } // OPEN API specs look like this (only kept the parts we're interested in): @@ -63,7 +56,7 @@ export const buildAPIPermissionScopeMap = async (): Promise // KNOWN DIVERGENCE: annotations are trusted verbatim, and the one on // POST /v1/projects/{ref}/database/query overstates access — the spec publishes // `[[database_read], [database_write]]`, but the route's guard requires database_read outright and -// the write group is doc-only (see the execute_sql entry in MCPToolScopeMappings.ts). A token +// the write group is doc-only (the MCP endpoint reports execute_sql under database_read only). A token // granted only database_write is therefore shown this endpoint as callable when the guard would // reject it. Studio-created tokens can't hit this (write mode always grants the read scopes too), // so this stays a display inaccuracy for API-created tokens; the fix is correcting the annotation @@ -147,6 +140,38 @@ const fetchAPIPermissionScope = async (version: 'v1' | 'v2') => { } } +// The mgmt-api endpoint that projects the MCP_TOOL_AUTH descriptor (which also drives enforcement) +// to tool -> FGA permission groups. Fetched like the OpenAPI spec above. +const fetchMcpToolPermissions = async () => { + try { + const response = await fetch(`${NEXT_PUBLIC_API_DOMAIN}/platform/mcp-tools-permissions`, { + method: 'get', + headers: { + 'Content-Type': 'application/json', + }, + }) + if (response.ok) { + return response.json() + } + const responseText = await response.text() + + const retryAfter = response.headers.get('Retry-After') ?? undefined + throw new InternalServerError(`MCP tool permissions responded with ${response.status}`, { + status: response.status, + body: responseText, + ...(retryAfter !== undefined && { retryAfter }), + }) + } catch (error: unknown) { + if (error instanceof InternalServerError) { + throw error + } + + if (error instanceof Error) { + throw new InternalServerError(error.message) + } + } +} + // Simplified OPEN API specs schemas that only defines what we care about for scoped tokens const OPEN_API_PATH_METHOD_SCHEMA = z.object({ @@ -172,3 +197,6 @@ const OPEN_API_PATH_ITEM_SCHEMA = z.preprocess( const API_SPECS_SCHEMA = z.object({ paths: z.record(z.string(), OPEN_API_PATH_ITEM_SCHEMA), }) + +// tool name -> OR-of-AND FGA permission groups, as served by GET /platform/mcp-tools-permissions. +const MCP_TOOLS_SCHEMA: z.ZodType = z.record(z.string(), z.array(z.array(z.string()))) diff --git a/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.test.tsx b/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.test.tsx new file mode 100644 index 0000000000000..409d837460880 --- /dev/null +++ b/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.test.tsx @@ -0,0 +1,116 @@ +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import type { components } from 'api-types' +import { HttpResponse } from 'msw' +import { Button } from 'ui' +import { useCurrentPage, useSetPage } from 'ui-patterns/CommandMenu' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useApiKeysCommands } from './ApiKeys' +import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock } from '@/tests/lib/msw' + +type ApiKeyResponse = components['schemas']['ApiKeyResponse'] + +const { mockUseAsyncCheckPermissions, mockUseHighAvailability, mockUseSelectedProjectQuery } = + vi.hoisted(() => ({ + mockUseAsyncCheckPermissions: vi.fn(), + mockUseHighAvailability: vi.fn(), + mockUseSelectedProjectQuery: vi.fn(), + })) + +vi.mock('@/hooks/misc/useCheckPermissions', () => ({ + useAsyncCheckPermissions: mockUseAsyncCheckPermissions, +})) + +vi.mock('@/hooks/misc/useHighAvailability', () => ({ + useHighAvailability: mockUseHighAvailability, +})) + +vi.mock('@/hooks/misc/useSelectedProject', () => ({ + useSelectedProjectQuery: mockUseSelectedProjectQuery, +})) + +const API_KEYS: ApiKeyResponse[] = [ + { api_key: 'anon-key', name: 'anon', type: 'legacy' }, + { api_key: 'service-key', name: 'service_role', type: 'legacy' }, + { + api_key: 'publishable-key', + hash: 'hash', + id: 'publishable-id', + inserted_at: '2025-02-16T22:24:42.115195Z', + name: 'default', + type: 'publishable', + }, + { + api_key: 'secret-key', + hash: 'hash', + id: 'secret-id', + inserted_at: '2025-02-16T22:24:42.115195Z', + name: 'sb_secret', + type: 'secret', + }, +] + +/** Renders the API keys command page so its commands can be asserted on. */ +const CommandPageHarness = () => { + useApiKeysCommands() + const setPage = useSetPage() + const page = useCurrentPage() + const commands = + page && 'sections' in page ? page.sections.flatMap((section) => section.commands) : [] + + return ( + <> + +
    + {commands.map((command) => ( +
  • {command.name}
  • + ))} +
+ + ) +} + +async function renderCommandPage() { + customRender() + + await userEvent.click(screen.getByRole('button', { name: 'Open API keys page' })) +} + +describe('useApiKeysCommands', () => { + beforeEach(() => { + vi.clearAllMocks() + + mockUseAsyncCheckPermissions.mockReturnValue({ can: true }) + mockUseSelectedProjectQuery.mockReturnValue({ + data: { id: 1, ref: 'default', name: 'default' }, + }) + mockUseHighAvailability.mockReturnValue({ isHighAvailability: false, isPending: false }) + addAPIMock({ + method: 'get', + path: '/v1/projects/:ref/api-keys', + response: () => HttpResponse.json(API_KEYS), + }) + }) + + it('omits the legacy key commands on High Availability projects', async () => { + mockUseHighAvailability.mockReturnValue({ isHighAvailability: true, isPending: false }) + + await renderCommandPage() + + expect(await screen.findByText('Copy publishable key')).toBeInTheDocument() + expect(screen.getByText('Copy secret key (sb_secret)')).toBeInTheDocument() + expect(screen.queryByText('Copy anonymous API key')).not.toBeInTheDocument() + expect(screen.queryByText('Copy service API key')).not.toBeInTheDocument() + }) + + it('includes the legacy key commands on other projects', async () => { + await renderCommandPage() + + expect(await screen.findByText('Copy anonymous API key')).toBeInTheDocument() + expect(screen.getByText('Copy service API key')).toBeInTheDocument() + expect(screen.getByText('Copy publishable key')).toBeInTheDocument() + expect(screen.getByText('Copy secret key (sb_secret)')).toBeInTheDocument() + }) +}) diff --git a/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx b/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx index d8ad22c076374..7788073e507c9 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx @@ -17,6 +17,7 @@ import { COMMAND_MENU_SECTIONS } from './CommandMenu.utils' import { orderCommandSectionsByPriority } from './ordering' import { useAPIKeys } from '@/data/api-keys/api-keys-query' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' +import { useHighAvailability } from '@/hooks/misc/useHighAvailability' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' const API_KEYS_PAGE_NAME = 'API Keys' @@ -30,15 +31,22 @@ export function useApiKeysCommands() { const ref = project?.ref || '_' const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') + const { isHighAvailability } = useHighAvailability() const { data: apiKeysData } = useAPIKeys( { projectRef: project?.ref, reveal: true }, { enabled: canReadAPIKeys } ) const commands = useMemo(() => { - const { anonKey, serviceKey, publishableKey, allSecretKeys } = canReadAPIKeys - ? (apiKeysData ?? {}) - : {} + const { + anonKey: legacyAnonKey, + serviceKey: legacyServiceKey, + publishableKey, + allSecretKeys, + } = canReadAPIKeys ? (apiKeysData ?? {}) : {} + + const anonKey = isHighAvailability ? undefined : legacyAnonKey + const serviceKey = isHighAvailability ? undefined : legacyServiceKey return [ project && @@ -127,7 +135,7 @@ export function useApiKeysCommands() { icon: () => , }, ].filter(Boolean) as ICommand[] - }, [canReadAPIKeys, apiKeysData, project, ref, resetCommandMenu, setIsOpen]) + }, [canReadAPIKeys, apiKeysData, isHighAvailability, project, ref, resetCommandMenu, setIsOpen]) useRegisterPage( API_KEYS_PAGE_NAME, diff --git a/apps/studio/components/interfaces/App/FeaturePreview/ExplorerPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/ExplorerPreview.tsx new file mode 100644 index 0000000000000..f64d5fef76987 --- /dev/null +++ b/apps/studio/components/interfaces/App/FeaturePreview/ExplorerPreview.tsx @@ -0,0 +1,55 @@ +import { useParams } from 'common' +import Image from 'next/image' + +import { useIsExplorerEnabled } from './FeaturePreviewContext' +import { InlineLink } from '@/components/ui/InlineLink' +import { BASE_PATH } from '@/lib/constants' + +export const ExplorerPreview = () => { + const { ref } = useParams() + const isExplorerEnabled = useIsExplorerEnabled() + + return ( +
+

+ The Explorer is a new unified workspace for querying your data and chatting with the + Assistant, and is an early preview of where we're heading with the SQL Editor. +

+

+ Notebooks are the first new feature of the Explorer — mix query cells and markdown notes in + a single document, so your queries and context stay together. Use them to write runbooks, + document incidents, build reusable reports, and more! +

+ + explorer-preview + +
+

Enabling this preview will:

+
    +
  • + Replace the existing SQL Editor with the new{' '} + + Explorer + + . +
      +
    • + We're looking to replace the SQL Editor with the Explorer in the long term, but for + now it lives alongside the SQL Editor, toggleable via this feature preview. +
    • +
    +
  • +
  • Enable managing of Notebooks through both the dashboard and the Assistant.
  • +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index 2eeb77a318fc3..739b178761eaf 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -165,6 +165,12 @@ export const useIsDatabaseConnectionsEnabled = () => { } } +export const useIsExplorerEnabled = () => { + const { flags } = useFeaturePreviewContext() + const isExplorerEnabled = useFlag('explorer') + return isExplorerEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_EXPLORER] +} + export const useFeaturePreviewModal = () => { const featurePreviews = useFeaturePreviews() const [featurePreviewModal, setFeaturePreviewModal] = useQueryState('featurePreviewModal') diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx index 24ad9662f92e0..ff43cc11831fa 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx @@ -34,6 +34,7 @@ import { import { AdvisorRulesPreview } from './AdvisorRulesPreview' import { CLSPreview } from './CLSPreview' import { DatabaseConnectionsPreview } from './DatabaseConnectionsPreview' +import { ExplorerPreview } from './ExplorerPreview' import { useFeaturePreviewContext, useFeaturePreviewModal } from './FeaturePreviewContext' import { IntegrationsLayoutPreview } from './IntegrationsLayoutPreview' import { JitDbAccessPreview } from './JitDbAccessPreview' @@ -59,6 +60,7 @@ const FEATURE_PREVIEW_KEY_TO_CONTENT: { [LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE]: , [LOCAL_STORAGE_KEYS.UI_PREVIEW_MARKETPLACE]: , [LOCAL_STORAGE_KEYS.UI_PREVIEW_DATABASE_CONNECTIONS]: , + [LOCAL_STORAGE_KEYS.UI_PREVIEW_EXPLORER]: , } export const FeaturePreviewModal = () => { diff --git a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts index 607337c5384a3..797164a05e753 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts +++ b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts @@ -21,7 +21,7 @@ export type FeaturePreview = { */ isForced?: boolean /** Optional category that the feature preview falls under, defaults to "Others" in the UI otherwise */ - category?: 'observability' | 'database' + category?: 'observability' | 'database' | 'editors' /** * Where to send the user after enabling, to try the feature out. Omit if the * feature has no single destination (e.g. a global layout change). @@ -35,11 +35,24 @@ export const useFeaturePreviews = (): FeaturePreview[] => { const jitDbAccessEnabled = useFlag('jitDbAccess') const isMarketplaceEnabled = useFlag('marketplaceIntegrations') const isDatabaseConnectionsEnabled = useFlag('topForPostgres') + const isExplorerEnabled = useFlag('explorer') const isSqlEditorManualSaveForced = useFlag('sqlEditorManualSaveForced') return useMemo(() => { const previews: FeaturePreview[] = [ + { + key: LOCAL_STORAGE_KEYS.UI_PREVIEW_EXPLORER, + name: 'Explorer & Notebooks', + category: 'editors', + // [Joshen TODO] Update with proper URL once discussion is up + discussionsUrl: undefined, + enabled: isExplorerEnabled, + isNew: true, + isPlatformOnly: true, + isDefaultOptIn: true, + getRoute: (ref?: string) => `/project/${ref}/explorer`, + }, { key: LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS, name: 'Updated Logs interface', @@ -114,6 +127,7 @@ export const useFeaturePreviews = (): FeaturePreview[] => { }, { key: LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE, + category: 'editors', name: 'Disable snippet auto-saving', discussionsUrl: undefined, isNew: true, @@ -146,5 +160,6 @@ export const useFeaturePreviews = (): FeaturePreview[] => { jitDbAccessEnabled, isMarketplaceEnabled, isDatabaseConnectionsEnabled, + isExplorerEnabled, ]) } diff --git a/apps/studio/components/interfaces/ConfigDrift/ConfigurationDriftPage.test.tsx b/apps/studio/components/interfaces/ConfigDrift/ConfigurationDriftPage.test.tsx index 36c6f00a3ece2..138b20a8d7c5b 100644 --- a/apps/studio/components/interfaces/ConfigDrift/ConfigurationDriftPage.test.tsx +++ b/apps/studio/components/interfaces/ConfigDrift/ConfigurationDriftPage.test.tsx @@ -120,6 +120,7 @@ function createProjectConfigResponse(auth: Record): V2ProjectCo }, auth, database: { + major_version: 17, network_restrictions: { allowed_cidrs: [], entitlement: 'disallowed', diff --git a/apps/studio/components/interfaces/Sidebar.tsx b/apps/studio/components/interfaces/Sidebar.tsx index 3a5ff661715b3..92ac2fb411f54 100644 --- a/apps/studio/components/interfaces/Sidebar.tsx +++ b/apps/studio/components/interfaces/Sidebar.tsx @@ -33,8 +33,8 @@ import { Route } from '../ui/ui.types' import { generateProductRoutes, generateSettingsRoutes, - generateToolRoutes, useGenerateOtherRoutes, + useGenerateToolRoutes, } from '@/components/layouts/Navigation/NavigationBar/NavigationBar.utils' import { ProjectIndexPageLink } from '@/data/prefetchers/project.$ref' import { useHideSidebar } from '@/hooks/misc/useHideSidebar' @@ -168,6 +168,7 @@ export function SideBarNavLink({ } & ComponentPropsWithoutRef) { const router = useRouter() const { state: sidebarState } = useSidebar() + const [sidebarBehaviour] = useLocalStorageQuery( LOCAL_STORAGE_KEYS.SIDEBAR_BEHAVIOR, DEFAULT_SIDEBAR_BEHAVIOR @@ -223,6 +224,7 @@ export function SideBarNavLink({ onTrigger={() => router.push(route.link!)} side="right" delayDuration={shortcutPopoverDelay} + label={route.key === 'explorer' ? 'Go to Explorer' : undefined} > {button} @@ -278,7 +280,7 @@ const ProjectLinks = () => { const authOverviewPageEnabled = useFlag('authOverviewPage') const workersEnabled = useFlag('workers') - const toolRoutes = generateToolRoutes(ref, project) + const toolRoutes = useGenerateToolRoutes() const productRoutes = generateProductRoutes(ref, project, { auth: authEnabled, edgeFunctions: edgeFunctionsEnabled, diff --git a/apps/studio/components/layouts/APIKeys/APIKeysLayout.test.tsx b/apps/studio/components/layouts/APIKeys/APIKeysLayout.test.tsx new file mode 100644 index 0000000000000..9dffd1d9b6d66 --- /dev/null +++ b/apps/studio/components/layouts/APIKeys/APIKeysLayout.test.tsx @@ -0,0 +1,51 @@ +import { screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import ApiKeysLayout from './APIKeysLayout' +import { customRender } from '@/tests/lib/custom-render' + +const { mockUseHighAvailability } = vi.hoisted(() => ({ + mockUseHighAvailability: vi.fn(), +})) + +vi.mock('@/hooks/misc/useHighAvailability', () => ({ + useHighAvailability: mockUseHighAvailability, +})) + +const LEGACY_TAB = 'Legacy anon, service_role API keys' +const NEW_TAB = 'Publishable and secret API keys' + +describe('ApiKeysLayout', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('hides the legacy keys tab on High Availability projects', () => { + mockUseHighAvailability.mockReturnValue({ isHighAvailability: true, isPending: false }) + + customRender( + +
content
+
+ ) + + expect(screen.getByText(NEW_TAB)).toBeInTheDocument() + expect(screen.queryByText(LEGACY_TAB)).not.toBeInTheDocument() + }) + + it('shows the legacy keys tab on other projects', () => { + mockUseHighAvailability.mockReturnValue({ isHighAvailability: false, isPending: false }) + + customRender( + +
content
+
+ ) + + expect(screen.getByText(NEW_TAB)).toBeInTheDocument() + expect(screen.getByRole('link', { name: LEGACY_TAB })).toHaveAttribute( + 'href', + '/project/default/settings/api-keys/legacy' + ) + }) +}) diff --git a/apps/studio/components/layouts/APIKeys/APIKeysLayout.tsx b/apps/studio/components/layouts/APIKeys/APIKeysLayout.tsx index 2d396b6db250e..1e5b382d02364 100644 --- a/apps/studio/components/layouts/APIKeys/APIKeysLayout.tsx +++ b/apps/studio/components/layouts/APIKeys/APIKeysLayout.tsx @@ -4,10 +4,12 @@ import { PropsWithChildren } from 'react' import { PageLayout } from '@/components/layouts/PageLayout/PageLayout' import { ScaffoldContainer } from '@/components/layouts/Scaffold' import { DocsButton } from '@/components/ui/DocsButton' +import { useHighAvailability } from '@/hooks/misc/useHighAvailability' import { DOCS_URL } from '@/lib/constants' const ApiKeysLayout = ({ children }: PropsWithChildren) => { const { ref: projectRef } = useParams() + const { isHighAvailability } = useHighAvailability() const navigationItems = [ { @@ -15,11 +17,15 @@ const ApiKeysLayout = ({ children }: PropsWithChildren) => { href: `/project/${projectRef}/settings/api-keys`, id: 'new-keys', }, - { - label: 'Legacy anon, service_role API keys', - href: `/project/${projectRef}/settings/api-keys/legacy`, - id: 'legacy-keys', - }, + ...(isHighAvailability + ? [] + : [ + { + label: 'Legacy anon, service_role API keys', + href: `/project/${projectRef}/settings/api-keys/legacy`, + id: 'legacy-keys', + }, + ]), ] return ( diff --git a/apps/studio/components/layouts/Navigation/NavigationBar/NavigationBar.utils.test.tsx b/apps/studio/components/layouts/Navigation/NavigationBar/NavigationBar.utils.test.tsx index 197c4ac5f5bd0..e7744288a85c8 100644 --- a/apps/studio/components/layouts/Navigation/NavigationBar/NavigationBar.utils.test.tsx +++ b/apps/studio/components/layouts/Navigation/NavigationBar/NavigationBar.utils.test.tsx @@ -1,10 +1,11 @@ -import { describe, expect, it } from 'vitest' +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { generateOtherRoutes, generateProductRoutes, generateSettingsRoutes, - generateToolRoutes, + useGenerateToolRoutes, } from './NavigationBar.utils' import type { Project } from '@/data/projects/project-detail-query' @@ -16,25 +17,55 @@ const inactiveProject = { status: 'INACTIVE' } as Project const keys = (routes: { key: string }[]) => routes.map((r) => r.key) -describe('generateToolRoutes', () => { +const mockUseParams = vi.fn() +const mockUseSelectedProjectQuery = vi.fn() +const mockUseIsExplorerEnabled = vi.fn() + +vi.mock('common', async (importOriginal) => ({ + ...(await importOriginal()), + useParams: () => mockUseParams(), +})) + +vi.mock('@/hooks/misc/useSelectedProject', () => ({ + useSelectedProjectQuery: () => mockUseSelectedProjectQuery(), +})) + +vi.mock('@/components/interfaces/App/FeaturePreview/FeaturePreviewContext', () => ({ + useIsExplorerEnabled: () => mockUseIsExplorerEnabled(), + useUnifiedLogsPreview: () => ({ isEnabled: false }), +})) + +describe('useGenerateToolRoutes', () => { + beforeEach(() => { + mockUseParams.mockReturnValue({ ref: REF }) + mockUseSelectedProjectQuery.mockReturnValue({ data: activeProject }) + mockUseIsExplorerEnabled.mockReturnValue(false) + }) + it('always returns Table Editor and SQL Editor', () => { - const routes = generateToolRoutes(REF, activeProject) - expect(keys(routes)).toEqual(['editor', 'sql']) + const { result } = renderHook(() => useGenerateToolRoutes()) + expect(keys(result.current)).toEqual(['editor', 'sql']) }) it('marks routes as disabled when project is not active', () => { - const routes = generateToolRoutes(REF, inactiveProject) - expect(routes.every((r) => r.disabled)).toBe(true) + mockUseSelectedProjectQuery.mockReturnValue({ data: inactiveProject }) + + const { result } = renderHook(() => useGenerateToolRoutes()) + expect(result.current.every((r) => r.disabled)).toBe(true) }) it('points links to the building URL when project is building', () => { - const routes = generateToolRoutes(REF, buildingProject) - expect(routes.every((r) => r.link === `/project/${REF}`)).toBe(true) + mockUseSelectedProjectQuery.mockReturnValue({ data: buildingProject }) + + const { result } = renderHook(() => useGenerateToolRoutes()) + expect(result.current.every((r) => r.link === `/project/${REF}`)).toBe(true) }) it('returns links as false when ref is undefined', () => { - const routes = generateToolRoutes(undefined, activeProject) - expect(routes.every((r) => r.link === undefined)).toBe(true) + mockUseParams.mockReturnValue({ ref: undefined }) + + const { result } = renderHook(() => useGenerateToolRoutes()) + expect(result.current.every((r) => r.link === undefined)).toBe(true) }) }) diff --git a/apps/studio/components/layouts/Navigation/NavigationBar/NavigationBar.utils.tsx b/apps/studio/components/layouts/Navigation/NavigationBar/NavigationBar.utils.tsx index fe5a74346a066..f1e81a74fa9c2 100644 --- a/apps/studio/components/layouts/Navigation/NavigationBar/NavigationBar.utils.tsx +++ b/apps/studio/components/layouts/Navigation/NavigationBar/NavigationBar.utils.tsx @@ -2,7 +2,10 @@ import { useParams } from 'common' import { Auth, Database, EdgeFunctions, Realtime, SqlEditor, Storage, TableEditor } from 'icons' import { Blocks, Box, Lightbulb, List, Settings, Telescope } from 'lucide-react' -import { useUnifiedLogsPreview } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { + useIsExplorerEnabled, + useUnifiedLogsPreview, +} from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { ICON_SIZE, ICON_STROKE_WIDTH } from '@/components/interfaces/Sidebar' import type { Route } from '@/components/ui/ui.types' import { EditorIndexPageLink } from '@/data/prefetchers/project.$ref.editor' @@ -45,8 +48,12 @@ function getRouteContext(ref?: string, project?: Project): RouteContext { } } -export const generateToolRoutes = (ref?: string, project?: Project): Route[] => { +export const useGenerateToolRoutes = (): Route[] => { + const { ref } = useParams() + const { data: project } = useSelectedProjectQuery() + const { isProjectActive, isProjectBuilding, buildingUrl } = getRouteContext(ref, project) + const isExplorerEnabled = useIsExplorerEnabled() return [ { @@ -58,14 +65,27 @@ export const generateToolRoutes = (ref?: string, project?: Project): Route[] => linkElement: , shortcutId: SHORTCUT_IDS.NAV_TABLE_EDITOR, }, - { - key: 'sql', - label: 'SQL Editor', - disabled: !isProjectActive, - icon: , - link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/sql`), - shortcutId: SHORTCUT_IDS.NAV_SQL_EDITOR, - }, + ...(isExplorerEnabled + ? [ + { + key: 'explorer', + label: 'Explorer', + disabled: !isProjectActive, + icon: , + link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/explorer`), + shortcutId: SHORTCUT_IDS.NAV_SQL_EDITOR, + }, + ] + : [ + { + key: 'sql', + label: 'SQL Editor', + disabled: !isProjectActive, + icon: , + link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/sql`), + shortcutId: SHORTCUT_IDS.NAV_SQL_EDITOR, + }, + ]), ] } diff --git a/apps/studio/components/layouts/Navigation/ProductMenuBar.tsx b/apps/studio/components/layouts/Navigation/ProductMenuBar.tsx index 53db489bf35e4..8c7f607021212 100644 --- a/apps/studio/components/layouts/Navigation/ProductMenuBar.tsx +++ b/apps/studio/components/layouts/Navigation/ProductMenuBar.tsx @@ -1,7 +1,5 @@ -import { useFlag, useParams } from 'common' -import Link from 'next/link' import { PropsWithChildren, ReactNode } from 'react' -import { Button, cn } from 'ui' +import { cn } from 'ui' interface ProductMenuBarProps { title: string @@ -15,11 +13,6 @@ export const ProductMenuBar = ({ children, className, }: PropsWithChildren) => { - // [Joshen] Temporary entry point into explorer - const { ref } = useParams() - const isExplorerEnabled = useFlag('explorer') - const showExplorerCTA = isExplorerEnabled && title === 'SQL Editor' - return (
{title} {titleBadge}
- {showExplorerCTA && ( - - )}
{children}
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/MobileMenuContent.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/MobileMenuContent.tsx index 89b81308333b5..30122bf5be7a9 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/MobileMenuContent.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/MobileMenuContent.tsx @@ -16,8 +16,8 @@ import { ICON_SIZE, ICON_STROKE_WIDTH } from '@/components/interfaces/Sidebar' import { generateProductRoutes, generateSettingsRoutes, - generateToolRoutes, useGenerateOtherRoutes, + useGenerateToolRoutes, } from '@/components/layouts/Navigation/NavigationBar/NavigationBar.utils' import type { Route } from '@/components/ui/ui.types' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' @@ -64,7 +64,7 @@ export function MobileMenuContent({ const authOverviewPageEnabled = useFlag('authOverviewPage') const workersEnabled = useFlag('workers') - const toolRoutes = useMemo(() => generateToolRoutes(ref, project), [ref, project]) + const toolRoutes = useGenerateToolRoutes() const productRoutes = useMemo( () => generateProductRoutes(ref, project, { diff --git a/apps/studio/pages/project/[ref]/settings/api-keys/legacy.tsx b/apps/studio/pages/project/[ref]/settings/api-keys/legacy.tsx index 39fa57ec7e7ac..1f3292b93b276 100644 --- a/apps/studio/pages/project/[ref]/settings/api-keys/legacy.tsx +++ b/apps/studio/pages/project/[ref]/settings/api-keys/legacy.tsx @@ -3,11 +3,25 @@ import { IS_PLATFORM } from 'common' import ApiKeysLayout from '@/components/layouts/APIKeys/APIKeysLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import SettingsLayout from '@/components/layouts/ProjectSettingsLayout/SettingsLayout' +import { HighAvailabilityDisabledEmptyState } from '@/components/ui/HighAvailability/HighAvailabilityDisabledEmptyState' import { DisplayApiSettings } from '@/components/ui/ProjectSettings/DisplayApiSettings' import { ToggleLegacyApiKeysPanel } from '@/components/ui/ProjectSettings/ToggleLegacyApiKeys' +import { useHighAvailability } from '@/hooks/misc/useHighAvailability' import type { NextPageWithLayout } from '@/types' const ApiKeysLegacyPage: NextPageWithLayout = () => { + const { isHighAvailability } = useHighAvailability() + + if (isHighAvailability) { + return ( + + ) + } + return ( <> diff --git a/apps/studio/public/img/previews/explorer-preview.png b/apps/studio/public/img/previews/explorer-preview.png new file mode 100644 index 0000000000000..227cd0909f52e Binary files /dev/null and b/apps/studio/public/img/previews/explorer-preview.png differ diff --git a/apps/studio/tests/pages/project/[ref]/settings/api-keys/legacy.test.tsx b/apps/studio/tests/pages/project/[ref]/settings/api-keys/legacy.test.tsx index cc6fa7683d807..620375fca7392 100644 --- a/apps/studio/tests/pages/project/[ref]/settings/api-keys/legacy.test.tsx +++ b/apps/studio/tests/pages/project/[ref]/settings/api-keys/legacy.test.tsx @@ -1,11 +1,13 @@ -import { render, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' import type { ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import ApiKeysLegacyPage from '@/pages/project/[ref]/settings/api-keys/legacy' +import { customRender } from '@/tests/lib/custom-render' -const { mockIsPlatform } = vi.hoisted(() => ({ +const { mockIsPlatform, mockUseHighAvailability } = vi.hoisted(() => ({ mockIsPlatform: { value: true }, + mockUseHighAvailability: vi.fn(), })) vi.mock('common', async () => { @@ -41,13 +43,18 @@ vi.mock('@/components/ui/ProjectSettings/ToggleLegacyApiKeys', () => ({ ToggleLegacyApiKeysPanel: () =>
ToggleLegacyApiKeysPanel
, })) +vi.mock('@/hooks/misc/useHighAvailability', () => ({ + useHighAvailability: mockUseHighAvailability, +})) + describe('/project/[ref]/settings/api-keys/legacy', () => { beforeEach(() => { mockIsPlatform.value = true + mockUseHighAvailability.mockReturnValue({ isHighAvailability: false, isPending: false }) }) it('renders both legacy keys and the disable toggle on platform', () => { - render() + customRender() expect(screen.getByText('DisplayApiSettings')).toBeInTheDocument() expect(screen.getByText('ToggleLegacyApiKeysPanel')).toBeInTheDocument() @@ -56,9 +63,21 @@ describe('/project/[ref]/settings/api-keys/legacy', () => { it('renders legacy keys but hides the disable toggle on self-hosted', () => { mockIsPlatform.value = false - render() + customRender() expect(screen.getByText('DisplayApiSettings')).toBeInTheDocument() expect(screen.queryByText('ToggleLegacyApiKeysPanel')).not.toBeInTheDocument() }) + + it('hides the legacy keys entirely on High Availability projects', () => { + mockUseHighAvailability.mockReturnValue({ isHighAvailability: true, isPending: false }) + + customRender() + + expect( + screen.getByText('Legacy API keys are unavailable on High Availability projects') + ).toBeInTheDocument() + expect(screen.queryByText('DisplayApiSettings')).not.toBeInTheDocument() + expect(screen.queryByText('ToggleLegacyApiKeysPanel')).not.toBeInTheDocument() + }) }) diff --git a/apps/www/_customers/lingo-dev.mdx b/apps/www/_customers/lingo-dev.mdx new file mode 100644 index 0000000000000..c6a94bb42e43c --- /dev/null +++ b/apps/www/_customers/lingo-dev.mdx @@ -0,0 +1,139 @@ +--- +name: Lingo.dev +title: How Lingo.dev clears enterprise security reviews without a single database question +description: Lingo.dev is the localization engineering platform behind retrieval augmented localization, translation APIs with a memory for glossary, brand voice, and quality. It has run on Supabase since day one. +meta_description: Lingo.dev runs retrieval augmented localization on Supabase Database, Vector, Auth, and Storage, and has never had a database question raised in an enterprise security review. +author: wendie_cheung +author_title: Product Marketing +logo: /images/customers/logos/on-light/lingo-dev.png +logo_inverse: /images/customers/logos/on-dark/lingo-dev.png +tags: + - supabase +date: '2026-08-26' +company_url: https://lingo.dev +misc: + [ + { label: 'Founded', text: 'Y Combinator F24, San Francisco' }, + { + label: 'Use case', + text: 'Retrieval augmented localization: translation APIs with a memory for glossary, brand voice, and quality', + }, + { label: 'Solutions', text: 'Database, Vector, Auth, Storage, Supabase MCP server' }, + ] +about: Lingo.dev is the localization engineering platform behind retrieval augmented localization, translation APIs with a memory for glossary, brand voice, and quality. +# "healthcare" | "fintech" | "ecommerce" | "education" | "gaming" | "media" | "real-estate" | "saas" | "social" | "analytics" | "ai" | "developer-tools" +industry: ['ai', 'developer-tools'] +# "startup" | "enterprise" | "indie_dev" +company_size: 'startup' +# "Asia" | "Europe" | "North America" | "South America" | "Africa" | "Oceania" +region: 'North America' +# "database" | "auth" | "storage" | "realtime" | "functions" | "vector" +supabase_products: ['database', 'vector', 'auth', 'storage'] +--- + + + Buy the encapsulation, and spend your resources only on your own category. Supabase encapsulates + the database layer so completely that we get to spend every engineering hour on localization + engineering infrastructure, which is the only place our customers can tell the difference. Our + users put it in a way I can't improve on: Stripe for payments, Supabase for databases, Lingo.dev + for localization. + + +[Lingo.dev](https://lingo.dev) is the localization engineering platform behind retrieval augmented localization. Teams configure localization engines, translation APIs that hold the glossary, brand voice, and quality rules for a product, and every translation request pulls that context before it runs. Lingo.dev has run on Supabase since day one. + +Lingo.dev started as a hackathon project in late 2023. The first version translated strings and nothing else. Users asked for more almost immediately. + + + We were getting so many feature requests that we had to stop and reconsider what the technology + actually was. What our users were describing wasn't a translation tool. It was a platform they + could engineer on. + + +Max built the company with co-founder Veronica Prilutskaya, moved it from Barcelona to San Francisco, took it through Y Combinator's F24 batch, and raised $4.2M to scale it. + +## The challenge + +Max and Veronica had built and sold a company before Lingo.dev. It ran on a non-relational document database, and the choice followed them for years. + + + We had all this data and we couldn't touch it the way we wanted. You can do the basics, but every + serious question we wanted to ask of our own data ran into the limits of the store we picked on + day one. The workarounds we built were incompatible with how we want infrastructure to work. + + +## Why they chose Supabase + +When Max and Veronica started Lingo.dev, the database was not up for debate. + + + We'd known Supabase since it launched. We'd tried it and knew: next company, this is what we build + on. It's relational, it's managed, and we never think about upgrading Postgres versions ourselves. + It was an obvious choice. + + +The decision predates everything else about the company, including its investors. Supabase CEO Paul Copplestone later joined as an investor in Lingo.dev. By then, Lingo.dev had already run on Supabase for its entire existence. + +The choice also shaped Lingo.dev's approach to compliance. Most startups treat SOC 2 as something to get once an enterprise deal demands it. Lingo.dev designed its security posture before writing its first line of code. + + + SOC 2 isn't a growth milestone anymore. It's table stakes. If you don't have it, you aren't taken + seriously. GDPR, SOC 2 Type II, where data lives, which regions: the architecture was shaped + around those requirements from day one. As a localization infrastructure company, we treat + security seriously. + + +## How Lingo.dev runs on Supabase + +Everything business critical at Lingo.dev lives in Supabase, including customers' localization engine configurations, glossaries, brand voices, customer organizations, and the embeddings that power the platform. When a translation request reaches a localization engine, Lingo.dev breaks the source content into phrases, embeds them, and runs a similarity search against the context corpus's vector indices using pgvector on Supabase. That search retrieves the terms, voice, and rules that match, and injects them into the model's context before it generates a translation. This retrieval step is what makes a localization engine stateful, and it runs entirely on Postgres. + + + pgvector played a real role in the decision, and it keeps earning it. We're happy with the + performance. Our retrieval augmented localization runs on Postgres, period. + + +Auth turned into an unexpected advantage. Because Lingo.dev's users are rows in the same Postgres database as the rest of the system, the team never had to sync an external identity provider or maintain a separate cache to keep user data consistent. + + + If we ever had to leave Supabase, that's what I'd miss first: never having to be our own database + administrators. + + +That advantage showed up early. While the team was closing its first enterprise customer, the prospect asked for Google sign in. Lingo.dev only had password auth at the time. + + + We were a bit nervous, and then we realized Google auth was already built into Supabase. It took a + couple of minutes to configure. We shipped it almost immediately, and the prospect was impressed + with how fast we moved. + + +Lingo.dev now uses the Supabase MCP server as part of its development workflow. The team's AI tooling works with the database directly. + +## Zero questions, every security review + +Lingo.dev's customers include Mistral AI, the Solana Foundation, and Veriff, an identity verification company whose infosec reviews rank among the toughest in software. None of those reviews has ever raised a question about the database. + + + We've been through some heavy procurement and security reviews, including companies that verify + identity for a living. The database layer has never been a question. Not negotiated, not + remediated. No questions, every time. + + +Building a SOC 2 ready database layer in house would have meant an operational burden Lingo.dev's team could not justify at YC speed. Max estimates the choice has saved the company two or more engineering hires, since the data layer runs without a dedicated database administrator. + + + Every time a fundamentally new technology appears, Supabase ships the integration the same week. + Almost nobody talks about this, and it might be the most underrated thing about them. A company + building on Supabase inherits that speed. + + +## The results + +- Zero database questions across every enterprise security review, including reviews from Mistral AI, the Solana Foundation, and Veriff +- Zero database incidents since Lingo.dev started +- 90 seconds from signup to a production database +- At least two engineering hires avoided because the database runs itself +- 25% of the Supabase platform in use, already worth the Team plan + +## What's next + +Lingo.dev plans to keep growing without growing headcount to match. Max says the team would rather invest in better tooling for the people already there than hire for every function Supabase already covers. Lingo.dev also uses its own product on itself: the company's websites, content, and communications are localized through Lingo.dev, on localization engines that run on Supabase. diff --git a/apps/www/_go/events/postgres-summit-2026/contest-thank-you.tsx b/apps/www/_go/events/postgres-summit-2026/contest-thank-you.tsx new file mode 100644 index 0000000000000..f4493cc57e650 --- /dev/null +++ b/apps/www/_go/events/postgres-summit-2026/contest-thank-you.tsx @@ -0,0 +1,36 @@ +import type { GoPageInput } from 'marketing' +import Link from 'next/link' +import { Button } from 'ui' + +const page: GoPageInput = { + template: 'thank-you', + slug: 'postgres-summit-2026/contest/thank-you', + metadata: { + title: "You're entered | Supabase at Postgres Summit US 2026", + description: 'Thanks for entering the Supabase contest at Postgres Summit US 2026. Good luck!', + }, + hero: { + title: 'Thanks for entering', + description: + "Your contest entry is confirmed. Make sure you've created a Supabase account and loaded data before the contest deadline. We'll reach out to the winner by email.", + }, + sections: [ + { + type: 'single-column', + title: 'Get started with Supabase', + description: "If you haven't already, create your account and start building.", + children: ( +
+ + +
+ ), + }, + ], +} + +export default page diff --git a/apps/www/_go/events/postgres-summit-2026/contest.tsx b/apps/www/_go/events/postgres-summit-2026/contest.tsx new file mode 100644 index 0000000000000..ef5baea9c552b --- /dev/null +++ b/apps/www/_go/events/postgres-summit-2026/contest.tsx @@ -0,0 +1,160 @@ +import type { GoPageInput } from 'marketing' +import Image from 'next/image' +import Link from 'next/link' +import { Button } from 'ui' + +const page: GoPageInput = { + template: 'lead-gen', + slug: 'postgres-summit-2026/contest', + metadata: { + title: 'Win a MacBook Neo | Supabase at Postgres Summit US 2026', + description: + 'Thanks for connecting with us at Postgres Summit US 2026. Try Supabase — Postgres with everything you need. Enter for a chance to win a MacBook Neo.', + }, + hero: { + title: 'Win a MacBook Neo', + subtitle: 'Supabase at Postgres Summit US 2026', + description: + 'Thanks for connecting with us at Postgres Summit US 2026. Try Supabase — Postgres with everything you need. Enter for a chance to win a MacBook Neo.', + image: { + src: '/images/landing-pages/sxsw-2026/macbook-neo.png', + alt: 'MacBook Neo in four colors', + width: 500, + height: 333, + }, + ctas: [ + { + label: 'Get started', + href: '#how-to-enter', + variant: 'primary', + }, + ], + }, + sections: [ + { + type: 'single-column', + title: 'Everything to know about Postgres Locks', + description: 'Conference Talk: Wednesday, September 30, 2026 4:00 PM EDT, Rossi Intermediate', + children: ( +
+ Brian Brennglass +
+

Brian Brennglass

+

Supabase

+
+ +
+ ), + }, + { + type: 'single-column', + id: 'how-to-enter', + title: 'How to enter', + children: ( +
+
    +
  1. Create a Supabase account and note the email address you used
  2. +
  3. Load data into a Supabase database
  4. +
  5. Fill out the entry form below
  6. +
  7. + Complete these steps by the contest deadline, Monday October 12, 2026 at 12:00 PM PDT +
  8. +
+ +

+ No purchase necessary. Void where prohibited.{' '} + + Official rules + + . +

+
+ ), + }, + { + type: 'form', + id: 'enter-contest', + title: 'Enter the contest', + description: 'Fill out the form below to complete your entry.', + fields: [ + { + type: 'text', + name: 'first_name', + label: 'First Name', + placeholder: 'First Name', + required: true, + half: true, + }, + { + type: 'text', + name: 'last_name', + label: 'Last Name', + placeholder: 'Last Name', + required: true, + half: true, + }, + { + type: 'email', + name: 'email_address', + label: 'Email', + placeholder: 'Email address', + required: true, + }, + { + type: 'text', + name: 'company_name', + label: 'Company', + placeholder: 'Company name', + required: true, + }, + ], + submitLabel: 'Enter contest', + successRedirect: '/go/postgres-summit-2026/contest/thank-you', + disclaimer: + 'By submitting this form, I confirm that I have read and understood the [Privacy Policy](https://supabase.com/privacy) and the [Official Rules](/go/contest-rules).', + crm: { + hubspot: { + formGuid: '32a0223e-784e-43bb-bdba-5cb3e72f35bd', + fieldMap: { + first_name: 'firstname', + last_name: 'lastname', + email_address: 'email', + company_name: 'name', + }, + consent: + 'By submitting this form, I confirm that I have read and understood the Privacy Policy.', + }, + customerio: { + event: 'event_attended', + profileMap: { + email_address: 'email', + first_name: 'first_name', + last_name: 'last_name', + company_name: 'company_name', + }, + staticProperties: { + event_name: 'Postgres Summit US 2026', + }, + }, + }, + }, + ], +} + +export default page diff --git a/apps/www/_go/index.tsx b/apps/www/_go/index.tsx index 91d6a643b4142..5e587993f9af9 100644 --- a/apps/www/_go/index.tsx +++ b/apps/www/_go/index.tsx @@ -8,6 +8,8 @@ import datadogDinner from './events/dash-2026/exec-dinner' import datadogContestThankYou from './events/dash-2026/exec-dinner-thank-you' import pgconfDev2026Contest from './events/pgconf-dev-2026/contest' import pgconfDev2026ContestThankYou from './events/pgconf-dev-2026/contest-thank-you' +import postgresSummit2026Contest from './events/postgres-summit-2026/contest' +import postgresSummit2026ContestThankYou from './events/postgres-summit-2026/contest-thank-you' import postgresconfContest from './events/postgresconf-sjc-2026/contest' import postgresconfContestThankYou from './events/postgresconf-sjc-2026/contest-thank-you' import selectPartnerDay from './events/select-2026/partner-day' @@ -53,6 +55,8 @@ const pages: GoPageInput[] = [ postgresconfContestThankYou, // remove after May 31, 2026 pgconfDev2026Contest, // remove after May 31, 2026 pgconfDev2026ContestThankYou, // remove after May 31, 2026 + postgresSummit2026Contest, // remove after October 31, 2026 + postgresSummit2026ContestThankYou, // remove after October 31, 2026 datadogContest, // remove after June 30, 2026 datadogContestThankYou, // remove after June 30, 2026 datadogDinner, // remove after June 30, 2026 diff --git a/apps/www/data/CustomerStories.ts b/apps/www/data/CustomerStories.ts index 9ea5d3981f674..835f8732bcfac 100644 --- a/apps/www/data/CustomerStories.ts +++ b/apps/www/data/CustomerStories.ts @@ -18,6 +18,18 @@ export type CustomerStoryType = { } export const data: CustomerStoryType[] = [ + { + type: 'Customer Story', + title: 'How Lingo.dev clears enterprise security reviews without a single database question', + description: + 'Lingo.dev is the localization engineering platform behind retrieval augmented localization: translation APIs with a memory for glossary, brand voice, and quality. It has run on Supabase since day one.', + organization: 'Lingo.dev', + imgUrl: 'images/customers/logos/on-light/lingo-dev.png', + logo: '/images/customers/logos/on-light/lingo-dev.png', + logo_inverse: '/images/customers/logos/on-dark/lingo-dev.png', + url: '/customers/lingo-dev', + ctaText: 'View story', + }, { type: 'Customer Story', title: 'How QA.tech built enterprise-ready AI testing agents on Supabase', diff --git a/apps/www/public/images/blog/avatars/max-lingo.jpg b/apps/www/public/images/blog/avatars/max-lingo.jpg new file mode 100644 index 0000000000000..ca483eeeacc9d Binary files /dev/null and b/apps/www/public/images/blog/avatars/max-lingo.jpg differ diff --git a/apps/www/public/images/blog/avatars/veronica-lingo.jpg b/apps/www/public/images/blog/avatars/veronica-lingo.jpg new file mode 100644 index 0000000000000..96b30e20ffc24 Binary files /dev/null and b/apps/www/public/images/blog/avatars/veronica-lingo.jpg differ diff --git a/apps/www/public/images/customers/logos/on-dark/lingo-dev.png b/apps/www/public/images/customers/logos/on-dark/lingo-dev.png new file mode 100644 index 0000000000000..2de91dae3cf90 Binary files /dev/null and b/apps/www/public/images/customers/logos/on-dark/lingo-dev.png differ diff --git a/apps/www/public/images/customers/logos/on-light/lingo-dev.png b/apps/www/public/images/customers/logos/on-light/lingo-dev.png new file mode 100644 index 0000000000000..fde5884cddba7 Binary files /dev/null and b/apps/www/public/images/customers/logos/on-light/lingo-dev.png differ diff --git a/apps/www/public/images/landing-pages/postgres-summit-NYC-2026/brian-brennglass.png b/apps/www/public/images/landing-pages/postgres-summit-NYC-2026/brian-brennglass.png new file mode 100644 index 0000000000000..ba19256432f05 Binary files /dev/null and b/apps/www/public/images/landing-pages/postgres-summit-NYC-2026/brian-brennglass.png differ diff --git a/packages/api-types/types/api-v1.d.ts b/packages/api-types/types/api-v1.d.ts index 8b38b0ed71cb0..5d7c1d4b205f6 100644 --- a/packages/api-types/types/api-v1.d.ts +++ b/packages/api-types/types/api-v1.d.ts @@ -566,7 +566,7 @@ export interface paths { * If both are not provided, only the last 1 minute of logs will be queried. * The timestamp range must be no more than 24 hours and is rounded to the nearest minute. If the range is more than 24 hours, a validation error will be thrown. * - * Note: Unless the `sql` parameter is provided, only edge_logs will be queried. See the [log query docs](/docs/guides/telemetry/logs?queryGroups=product&product=postgres&queryGroups=source&source=edge_logs#querying-with-the-logs-explorer:~:text=logs%20from%20the-,Sources,-drop%2Ddown%3A) for all available sources. + * Note: Unless the `sql` parameter is provided, only edge_logs will be queried. See the [log query docs](https://supabase.com/docs/guides/monitoring-and-debugging/logs#logs-explorer) for all available sources. * */ get: operations['v1-get-project-logs-all'] @@ -7102,7 +7102,7 @@ export interface operations { query?: { iso_timestamp_end?: string iso_timestamp_start?: string - /** @description Custom SQL query to execute on the logs. See [querying logs](/docs/guides/telemetry/logs?queryGroups=product&product=postgres&queryGroups=source&source=edge_logs#querying-with-the-logs-explorer) for more details. */ + /** @description Custom SQL query to execute on the logs. See [querying logs](https://supabase.com/docs/guides/monitoring-and-debugging/logs#querying-with-the-logs-explorer) for more details. */ sql?: string } header?: never @@ -7157,7 +7157,7 @@ export interface operations { query?: { iso_timestamp_end?: string iso_timestamp_start?: string - /** @description Custom SQL query to execute on the logs. See [querying logs](/docs/guides/telemetry/logs?queryGroups=product&product=postgres&queryGroups=source&source=edge_logs#querying-with-the-logs-explorer) for more details. */ + /** @description Custom SQL query to execute on the logs. See [querying logs](https://supabase.com/docs/guides/monitoring-and-debugging/logs#querying-with-the-logs-explorer) for more details. */ sql?: string } header?: never diff --git a/packages/api-types/types/api-v2.d.ts b/packages/api-types/types/api-v2.d.ts index bedf058b8a467..f616718eeef1b 100644 --- a/packages/api-types/types/api-v2.d.ts +++ b/packages/api-types/types/api-v2.d.ts @@ -1131,7 +1131,11 @@ export interface components { V2CreateInvitationsRequest: { data: { attributes: { - /** Format: email */ + /** + * Format: email + * @description Email address of the invitation receipient. + * @example hello@example.com + */ email: string /** @description The projects to limit a user to. If omitted, user will have org-wide access with the provided role. */ projects?: { @@ -1159,7 +1163,11 @@ export interface components { V2CreateInvitationsResponse: { data: { attributes: { - /** Format: email */ + /** + * Format: email + * @description Email address of the invitation receipient. + * @example hello@example.com + */ email: string } /** @@ -1190,7 +1198,11 @@ export interface components { } message: string meta: { - /** Format: email */ + /** + * Format: email + * @description Email address of the invitation receipient. + * @example hello@example.com + */ email: string } }[] @@ -1232,7 +1244,11 @@ export interface components { V2DeleteInvitationsRequest: { data: { attributes: { - /** Format: email */ + /** + * Format: email + * @description Email address of the invitation receipient. + * @example hello@example.com + */ email: string } /** @@ -1245,7 +1261,11 @@ export interface components { V2DeleteInvitationsResponse: { data: { attributes: { - /** Format: email */ + /** + * Format: email + * @description Email address of the invitation receipient. + * @example hello@example.com + */ email: string } /** @@ -1729,6 +1749,8 @@ export interface components { [key: string]: unknown } database: { + /** @description The major Postgres version the database runs. `17` covers both Postgres 17 and Oriole on 17, since Oriole is a storage engine rather than a version. */ + major_version: number network_restrictions: { allowed_cidrs: { address: string diff --git a/packages/api-types/types/platform.d.ts b/packages/api-types/types/platform.d.ts index ed2eb431fbb53..8f1706571cbe9 100644 --- a/packages/api-types/types/platform.d.ts +++ b/packages/api-types/types/platform.d.ts @@ -900,6 +900,26 @@ export interface paths { patch?: never trace?: never } + '/platform/mcp-tools-permissions': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** + * MCP tool → FGA permission map + * @description Returns each MCP tool and the FGA permission groups that gate it, as OR-of-AND alternatives (the token needs every permission of at least one group). Used by the dashboard to show what a scoped token can do. + */ + get: operations['get-mcp-tools-permissions'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/platform/notifications': { parameters: { query?: never @@ -1512,43 +1532,6 @@ export interface paths { patch?: never trace?: never } - '/platform/organizations/{slug}/documents/dpa': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - get?: never - put?: never - /** Create DPA document using PandaDoc */ - post: operations['OrgDocumentsController_createDpaDocument'] - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/platform/organizations/{slug}/documents/dpa-signed': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** - * Check if organization has signed any version of the DPA - * @description Results are cached per organization for up to 24 hours. Signed status may not reflect immediately after a document is completed. - */ - get: operations['OrgDocumentsController_getDpaSignedStatus'] - put?: never - post?: never - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } '/platform/organizations/{slug}/documents/iso27001-certificate': { parameters: { query?: never @@ -5241,6 +5224,14 @@ export interface components { oauth_app_name?: string /** @description Organization whose grant was used. Only present when token_type=oauth */ organization_id?: string + /** @description Marketplace partner that authenticated the request. Only present when token_type=partner */ + partner?: string + /** @description The partner's own integration-installation id. Only present when token_type=partner and the integration is already installed. Distinct from installation_id, which is a Supabase platform-app installation */ + partner_installation_id?: string + /** @description Email of the partner user who triggered the action. Only present when token_type=partner and the partner acted on behalf of one of its users */ + partner_user_email?: string + /** @description Opaque user identifier in the partner's namespace. Only present when token_type=partner and the partner acted on behalf of one of its users */ + partner_user_id?: string /** @description GoTrue login session. Only present when token_type=jwt */ session_id?: string /** @description Access token alias, as shown in the dashboard. Only present when token_type=v0, token_type=v1 or token_type=scoped_pat */ @@ -5293,6 +5284,8 @@ export interface components { /** @enum {boolean} */ clear_tax_id?: true dry_run?: boolean + /** Format: email */ + email?: string tax_id?: { country: string type: string @@ -5593,17 +5586,6 @@ export interface components { */ id: number } - CreateDpaDocumentRequest: { - /** Format: email */ - recipient_email: string - } - CreateDpaDocumentResponse: { - date_created: string - document_id: string - download_url?: string - name: string - status: string - } CreateGitHubAuthorizationBody: { code: string } @@ -5627,9 +5609,31 @@ export interface components { workdir: string } CreateInvitationBody: { - emails: string[] + data?: { + attributes: { + /** Format: email */ + email: string + /** @description The projects to limit a user to. If omitted, user will have org-wide access with the provided role. */ + projects?: { + /** + * @description Project ref + * @example abcjuqabhgwjjutfvtpa + */ + ref: string + }[] + require_sso?: boolean + /** + * @description Role name to assign. Must be on a Team or Enterprise plan to use the read-only role. + * @example developer + * @enum {string} + */ + role?: 'owner' | 'administrator' | 'developer' | 'read-only' + role_id?: number + } + }[] + emails?: string[] require_sso?: boolean - role_id: number + role_id?: number role_scoped_projects?: string[] } CreateInvitationResponse: { @@ -6207,6 +6211,79 @@ export interface components { project_id: string /** @description BigQuery service account key */ service_account_key: string + /** @description Per-table partitioning and clustering, applied only when the physical table is created or recreated */ + table_options?: { + tables?: { + cluster_by?: string[] + partition_by?: + | ( + | { + /** + * @description Source column name + * @example created_at + */ + column: string + /** + * @description Partition granularity + * @example day + * @enum {string} + */ + granularity?: 'hour' | 'day' | 'month' | 'year' + /** + * @description Partition by a replicated `DATE`, `TIMESTAMP`, or `DATETIME` column + * @enum {string} + */ + kind: 'time_column' + } + | { + /** + * @description Source column name + * @example created_at + */ + column: string + /** + * @description Exclusive end of the last partition range + * @example 100 + */ + end: number + /** + * @description Width of each partition range + * @example 10 + */ + interval: number + /** + * @description Partition by ranges of a replicated integer column + * @enum {string} + */ + kind: 'integer_range' + /** + * @description Inclusive start of the first partition range + * @example 0 + */ + start: number + } + | { + /** + * @description Partition granularity + * @example day + * @enum {string} + */ + granularity?: 'hour' | 'day' | 'month' | 'year' + /** + * @description Partition by the time at which BigQuery ingests each row + * @enum {string} + */ + kind: 'ingestion_time' + } + ) + | null + /** + * @description Source PostgreSQL table OID, stable across renames for the relation lifetime + * @example 16384 + */ + table_id: number + }[] + } } } | { @@ -6519,6 +6596,79 @@ export interface components { project_id: string /** @description BigQuery service account key */ service_account_key: string + /** @description Per-table partitioning and clustering, applied only when the physical table is created or recreated */ + table_options?: { + tables?: { + cluster_by?: string[] + partition_by?: + | ( + | { + /** + * @description Source column name + * @example created_at + */ + column: string + /** + * @description Partition granularity + * @example day + * @enum {string} + */ + granularity?: 'hour' | 'day' | 'month' | 'year' + /** + * @description Partition by a replicated `DATE`, `TIMESTAMP`, or `DATETIME` column + * @enum {string} + */ + kind: 'time_column' + } + | { + /** + * @description Source column name + * @example created_at + */ + column: string + /** + * @description Exclusive end of the last partition range + * @example 100 + */ + end: number + /** + * @description Width of each partition range + * @example 10 + */ + interval: number + /** + * @description Partition by ranges of a replicated integer column + * @enum {string} + */ + kind: 'integer_range' + /** + * @description Inclusive start of the first partition range + * @example 0 + */ + start: number + } + | { + /** + * @description Partition granularity + * @example day + * @enum {string} + */ + granularity?: 'hour' | 'day' | 'month' | 'year' + /** + * @description Partition by the time at which BigQuery ingests each row + * @enum {string} + */ + kind: 'ingestion_time' + } + ) + | null + /** + * @description Source PostgreSQL table OID, stable across renames for the relation lifetime + * @example 16384 + */ + table_id: number + }[] + } } } | { @@ -7564,11 +7714,6 @@ export interface components { } timestamp: string } - DocumentSignedStatusResponse: { - /** Format: date-time */ - checked_at: string - signed: boolean - } DownloadableBackupsResponse: { backups: { id: number @@ -10390,6 +10535,79 @@ export interface components { * @example my-gcp-project */ project_id: string + /** @description Per-table partitioning and clustering, applied only when the physical table is created or recreated */ + table_options?: { + tables?: { + cluster_by?: string[] + partition_by?: + | ( + | { + /** + * @description Source column name + * @example created_at + */ + column: string + /** + * @description Partition granularity + * @example day + * @enum {string} + */ + granularity?: 'hour' | 'day' | 'month' | 'year' + /** + * @description Partition by a replicated `DATE`, `TIMESTAMP`, or `DATETIME` column + * @enum {string} + */ + kind: 'time_column' + } + | { + /** + * @description Source column name + * @example created_at + */ + column: string + /** + * @description Exclusive end of the last partition range + * @example 100 + */ + end: number + /** + * @description Width of each partition range + * @example 10 + */ + interval: number + /** + * @description Partition by ranges of a replicated integer column + * @enum {string} + */ + kind: 'integer_range' + /** + * @description Inclusive start of the first partition range + * @example 0 + */ + start: number + } + | { + /** + * @description Partition granularity + * @example day + * @enum {string} + */ + granularity?: 'hour' | 'day' | 'month' | 'year' + /** + * @description Partition by the time at which BigQuery ingests each row + * @enum {string} + */ + kind: 'ingestion_time' + } + ) + | null + /** + * @description Source PostgreSQL table OID, stable across renames for the relation lifetime + * @example 16384 + */ + table_id: number + }[] + } } } | { @@ -10579,6 +10797,79 @@ export interface components { * @example my-gcp-project */ project_id: string + /** @description Per-table partitioning and clustering, applied only when the physical table is created or recreated */ + table_options?: { + tables?: { + cluster_by?: string[] + partition_by?: + | ( + | { + /** + * @description Source column name + * @example created_at + */ + column: string + /** + * @description Partition granularity + * @example day + * @enum {string} + */ + granularity?: 'hour' | 'day' | 'month' | 'year' + /** + * @description Partition by a replicated `DATE`, `TIMESTAMP`, or `DATETIME` column + * @enum {string} + */ + kind: 'time_column' + } + | { + /** + * @description Source column name + * @example created_at + */ + column: string + /** + * @description Exclusive end of the last partition range + * @example 100 + */ + end: number + /** + * @description Width of each partition range + * @example 10 + */ + interval: number + /** + * @description Partition by ranges of a replicated integer column + * @enum {string} + */ + kind: 'integer_range' + /** + * @description Inclusive start of the first partition range + * @example 0 + */ + start: number + } + | { + /** + * @description Partition granularity + * @example day + * @enum {string} + */ + granularity?: 'hour' | 'day' | 'month' | 'year' + /** + * @description Partition by the time at which BigQuery ingests each row + * @enum {string} + */ + kind: 'ingestion_time' + } + ) + | null + /** + * @description Source PostgreSQL table OID, stable across renames for the relation lifetime + * @example 16384 + */ + table_id: number + }[] + } } } | { @@ -12677,6 +12968,79 @@ export interface components { project_id?: string | null /** @description BigQuery service account key */ service_account_key?: string | null + /** @description Per-table partitioning and clustering, applied only when the physical table is created or recreated */ + table_options?: { + tables?: { + cluster_by?: string[] + partition_by?: + | ( + | { + /** + * @description Source column name + * @example created_at + */ + column: string + /** + * @description Partition granularity + * @example day + * @enum {string} + */ + granularity?: 'hour' | 'day' | 'month' | 'year' + /** + * @description Partition by a replicated `DATE`, `TIMESTAMP`, or `DATETIME` column + * @enum {string} + */ + kind: 'time_column' + } + | { + /** + * @description Source column name + * @example created_at + */ + column: string + /** + * @description Exclusive end of the last partition range + * @example 100 + */ + end: number + /** + * @description Width of each partition range + * @example 10 + */ + interval: number + /** + * @description Partition by ranges of a replicated integer column + * @enum {string} + */ + kind: 'integer_range' + /** + * @description Inclusive start of the first partition range + * @example 0 + */ + start: number + } + | { + /** + * @description Partition granularity + * @example day + * @enum {string} + */ + granularity?: 'hour' | 'day' | 'month' | 'year' + /** + * @description Partition by the time at which BigQuery ingests each row + * @enum {string} + */ + kind: 'ingestion_time' + } + ) + | null + /** + * @description Source PostgreSQL table OID, stable across renames for the relation lifetime + * @example 16384 + */ + table_id: number + }[] + } | null } } | { @@ -12985,6 +13349,79 @@ export interface components { project_id?: string | null /** @description BigQuery service account key */ service_account_key?: string | null + /** @description Per-table partitioning and clustering, applied only when the physical table is created or recreated */ + table_options?: { + tables?: { + cluster_by?: string[] + partition_by?: + | ( + | { + /** + * @description Source column name + * @example created_at + */ + column: string + /** + * @description Partition granularity + * @example day + * @enum {string} + */ + granularity?: 'hour' | 'day' | 'month' | 'year' + /** + * @description Partition by a replicated `DATE`, `TIMESTAMP`, or `DATETIME` column + * @enum {string} + */ + kind: 'time_column' + } + | { + /** + * @description Source column name + * @example created_at + */ + column: string + /** + * @description Exclusive end of the last partition range + * @example 100 + */ + end: number + /** + * @description Width of each partition range + * @example 10 + */ + interval: number + /** + * @description Partition by ranges of a replicated integer column + * @enum {string} + */ + kind: 'integer_range' + /** + * @description Inclusive start of the first partition range + * @example 0 + */ + start: number + } + | { + /** + * @description Partition granularity + * @example day + * @enum {string} + */ + granularity?: 'hour' | 'day' | 'month' | 'year' + /** + * @description Partition by the time at which BigQuery ingests each row + * @enum {string} + */ + kind: 'ingestion_time' + } + ) + | null + /** + * @description Source PostgreSQL table OID, stable across renames for the relation lifetime + * @example 16384 + */ + table_id: number + }[] + } | null } } | { @@ -13708,6 +14145,7 @@ export interface components { x_column: string y_series: string[] } + database_identifier?: string row_limit: number sql: string title?: string @@ -13802,6 +14240,14 @@ export interface components { oauth_app_name?: string /** @description Organization whose grant was used. Only present when token_type=oauth */ organization_id?: string + /** @description Marketplace partner that authenticated the request. Only present when token_type=partner */ + partner?: string + /** @description The partner's own integration-installation id. Only present when token_type=partner and the integration is already installed. Distinct from installation_id, which is a Supabase platform-app installation */ + partner_installation_id?: string + /** @description Email of the partner user who triggered the action. Only present when token_type=partner and the partner acted on behalf of one of its users */ + partner_user_email?: string + /** @description Opaque user identifier in the partner's namespace. Only present when token_type=partner and the partner acted on behalf of one of its users */ + partner_user_id?: string /** @description GoTrue login session. Only present when token_type=jwt */ session_id?: string /** @description Access token alias, as shown in the dashboard. Only present when token_type=v0, token_type=v1 or token_type=scoped_pat */ @@ -13931,6 +14377,79 @@ export interface components { project_id: string /** @description BigQuery service account key */ service_account_key: string + /** @description Per-table partitioning and clustering, applied only when the physical table is created or recreated */ + table_options?: { + tables?: { + cluster_by?: string[] + partition_by?: + | ( + | { + /** + * @description Source column name + * @example created_at + */ + column: string + /** + * @description Partition granularity + * @example day + * @enum {string} + */ + granularity?: 'hour' | 'day' | 'month' | 'year' + /** + * @description Partition by a replicated `DATE`, `TIMESTAMP`, or `DATETIME` column + * @enum {string} + */ + kind: 'time_column' + } + | { + /** + * @description Source column name + * @example created_at + */ + column: string + /** + * @description Exclusive end of the last partition range + * @example 100 + */ + end: number + /** + * @description Width of each partition range + * @example 10 + */ + interval: number + /** + * @description Partition by ranges of a replicated integer column + * @enum {string} + */ + kind: 'integer_range' + /** + * @description Inclusive start of the first partition range + * @example 0 + */ + start: number + } + | { + /** + * @description Partition granularity + * @example day + * @enum {string} + */ + granularity?: 'hour' | 'day' | 'month' | 'year' + /** + * @description Partition by the time at which BigQuery ingests each row + * @enum {string} + */ + kind: 'ingestion_time' + } + ) + | null + /** + * @description Source PostgreSQL table OID, stable across renames for the relation lifetime + * @example 16384 + */ + table_id: number + }[] + } } } | { @@ -17322,6 +17841,28 @@ export interface operations { } } } + 'get-mcp-tools-permissions': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody?: never + responses: { + /** @description Map of MCP tool name to its FGA permission groups (OR-of-AND). */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': { + [key: string]: string[][] + } + } + } + } + } NotificationsController_getNotifications: { parameters: { query?: { @@ -19761,96 +20302,6 @@ export interface operations { } } } - OrgDocumentsController_createDpaDocument: { - parameters: { - query?: never - header?: never - path: { - /** @description Organization slug */ - slug: string - } - cookie?: never - } - requestBody: { - content: { - 'application/json': components['schemas']['CreateDpaDocumentRequest'] - } - } - responses: { - 201: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['CreateDpaDocumentResponse'] - } - } - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Forbidden action */ - 403: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Rate limit exceeded */ - 429: { - headers: { - [name: string]: unknown - } - content?: never - } - } - } - OrgDocumentsController_getDpaSignedStatus: { - parameters: { - query?: never - header?: never - path: { - /** @description Organization slug */ - slug: string - } - cookie?: never - } - requestBody?: never - responses: { - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['DocumentSignedStatusResponse'] - } - } - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Forbidden action */ - 403: { - headers: { - [name: string]: unknown - } - content?: never - } - /** @description Rate limit exceeded */ - 429: { - headers: { - [name: string]: unknown - } - content?: never - } - } - } OrgDocumentsController_getIso27001CertificateUrl: { parameters: { query?: never diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index d159268e6df46..cf3e8259db9c1 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -28,6 +28,7 @@ export const LOCAL_STORAGE_KEYS = { UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE: 'supabase-ui-sql-editor-manual-save', UI_PREVIEW_MARKETPLACE: 'supabase-ui-marketplace', UI_PREVIEW_DATABASE_CONNECTIONS: 'preview-database-connections', + UI_PREVIEW_EXPLORER: 'preview-explorer', AI_ASSISTANT_MCP_OPT_IN: 'ai-assistant-mcp-opt-in',