diff --git a/apps/docs/content/guides/database/postgres/row-level-security.mdx b/apps/docs/content/guides/database/postgres/row-level-security.mdx index 8099f42bb6743..460e2aa761191 100644 --- a/apps/docs/content/guides/database/postgres/row-level-security.mdx +++ b/apps/docs/content/guides/database/postgres/row-level-security.mdx @@ -11,21 +11,24 @@ When you need granular authorization rules, nothing beats Postgres's [Row Level -Supabase allows convenient and secure data access from the browser, as long as you enable RLS. +A table in an exposed schema without RLS is readable and writable by anyone with your publishable key. RLS must always be enabled on any table stored in an exposed schema. By default, this is the `public` schema. -RLS _must_ always be enabled on any tables stored in an exposed schema. By default, this is the `public` schema. - -RLS is enabled by default on tables created with the Table Editor in the dashboard. If you create one in raw SQL or with the SQL editor, remember to enable RLS yourself and grant only the permissions each Postgres role needs. +RLS is enabled by default on tables created with the Table Editor in the dashboard. If you create a table in raw SQL or with the SQL editor, enable RLS yourself and leave each role only the privileges it needs: ```sql -GRANT SELECT ON . TO anon; -GRANT SELECT, INSERT, UPDATE, DELETE ON . TO authenticated; -GRANT SELECT, INSERT, UPDATE, DELETE ON . TO service_role; +-- Take back the privileges granted automatically to client roles. +revoke all on table . from anon, authenticated; + +-- Grant back only what each role needs. +grant select on table . to anon, authenticated; +grant insert, update, delete on table . to authenticated; alter table . enable row level security; ``` +Policies alone don't do this. See [Grants and policies](#grants-and-policies). + RLS is incredibly powerful and flexible, allowing you to write complex SQL rules that fit your unique business needs. RLS can be combined with [Supabase Auth](/docs/guides/auth) for end-to-end user security from the browser to the database. @@ -63,6 +66,54 @@ alter table "table_name" enable row level security; Once you have enabled RLS, no data will be accessible via the [API](/docs/guides/api) when using a publishable key, until you create policies. +## Grants and policies + +Postgres runs two checks before a client touches a table. Grants decide whether a role can run an operation on the table at all. Policies decide which rows that operation applies to. Set both for every table you expose. + +On existing projects, a new table in `public` starts with every privilege already granted to all three roles: + +| Role | Granted automatically | What it should keep | +| --------------- | -------------------------------------- | ------------------------------------------------------- | +| `anon` | `select`, `insert`, `update`, `delete` | Only what signed-out visitors are meant to read | +| `authenticated` | `select`, `insert`, `update`, `delete` | Only the operations your app exposes to signed-in users | +| `service_role` | `select`, `insert`, `update`, `delete` | Full access. It bypasses RLS, so keep it server-side | + +Adding policies doesn't take those grants back. A table protected only by policies still hands `anon` an insert path if you never revoke the grant. + +A missing grant raises a `42501` error before any policy runs. When a request fails that your policy should allow, check the grants before you change the policy. + +### Set the grants for a table + +Run these statements in the [SQL Editor](/dashboard/project/_/sql/new) for a one-off change, or in a [migration](/docs/guides/deployment/database-migrations) to keep the change reproducible across environments. Grants and RLS belong in the same migration. + +Set the grants to match what each role does in your app: + +1. Revoke the automatic grants from both client roles. + + ```sql + revoke all on table public.reports from anon, authenticated; + ``` + +2. Grant back only the privileges the role needs. + + ```sql + -- Signed-in users manage reports. Signed-out visitors get nothing. + grant select, insert, update, delete on table public.reports to authenticated; + ``` + +3. Enable RLS on the table and write policies that decide which rows each role reaches. + +For data that clients read but never write, such as a feed a backend job populates, grant no writes in step 2: + +```sql +revoke all on table public.weather_readings from anon, authenticated; +grant select on table public.weather_readings to anon, authenticated; +``` + +To stop new tables from receiving the automatic grants in the first place, see [Revoke default privileges](/docs/guides/api/securing-your-api#revoke-default-privileges). + +Write the tests for this table in the same change. See [Test your policies](#test-your-policies). + ## Auto-enable RLS for new tables If you want RLS enabled automatically for new tables, you can create an event trigger that runs after table creation. This uses a Postgres [event trigger](/docs/guides/database/postgres/event-triggers) to call `ALTER TABLE ... ENABLE ROW LEVEL SECURITY` on each newly created table. @@ -369,6 +420,142 @@ alter role "role_name" with bypassrls; This can be useful for system-level access. You should _never_ share login credentials for any Postgres Role with this privilege. +## Test your policies + +We recommend writing tests for every policy, in the same change that sets the grants and creates the policies. Tests are a fundamental part of a secure setup, and they give you a repeatable way to prove a policy behaves the way you intended. + +A wrong policy fails quietly. Too permissive, and a query returns rows it shouldn't. Too strict, and it returns nothing and raises no error. Neither case surfaces as an error, so tests are how you find out. + +Supabase runs database tests with [pgTAP](/docs/guides/database/extensions/pgtap) through the CLI. Test files are `.sql` files under `supabase/tests/`. + +### Anatomy of a policy test + +Each case sets an identity, runs one statement as that identity, and asserts the outcome. Three things decide whether the assertion means anything. + +**Identity.** Switch role and identity between cases with `set local role` and `set local request.jwt.claim.sub`, so each assertion runs as the user it describes. Without the switch, every case runs as the same role and proves nothing about access. + +**Denials.** A denied request doesn't always raise an error, so match the assertion to the way the denial happens: + +- A missing grant raises `42501`. Assert it with `throws_ok`. +- A `with check` violation raises `42501`. Assert it with `throws_ok`. +- A `using` clause that filters the target row out raises nothing. The update or delete matches zero rows instead. Assert that no row changed. + +**Allowed writes.** The absence of an error doesn't prove that anything changed. Add `returning` to the statement so one assertion covers both directions. An allowed write returns the changed row, and a write the policy filters out returns nothing. + +### Write and run the tests + +1. Create the tests directory and a test file: + + ```bash + mkdir -p supabase/tests + touch supabase/tests/profiles_rls.test.sql + ``` + +2. Write the tests. Cover `select`, `insert`, `update`, and `delete` twice each, once for a request the policy allows and once for a request it denies. Cover `anon` as well as `authenticated`. + +3. Run the suite: + + ```bash + supabase test db + ``` + +This example tests a `profiles` table where `authenticated` holds every privilege, `anon` holds none, and each user reads and writes only their own row: + +```sql supabase/tests/profiles_rls.test.sql +begin; +select plan(11); + +-- Seed two users. The rows come later, through the policies under test. +insert into auth.users (id, email) +values + ('11111111-1111-1111-1111-111111111111', 'owner@example.com'), + ('22222222-2222-2222-2222-222222222222', 'other@example.com'); + +-- Signed-out visitors hold no grant, so the request stops before any policy runs. +set local role anon; +select throws_ok( + $$select * from profiles$$, + '42501', + null, + 'anon cannot read profiles' +); +select throws_ok( + $$insert into profiles (id, user_id) + values (gen_random_uuid(), '11111111-1111-1111-1111-111111111111')$$, + '42501', + null, + 'anon cannot insert a profile' +); + +-- The owner reads and writes their own row. +set local role authenticated; +set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111'; +select results_eq( + $$insert into profiles (id, user_id, avatar_url) + values ( + gen_random_uuid(), + '11111111-1111-1111-1111-111111111111', + 'owner.png' + ) + returning avatar_url$$, + array['owner.png'], + 'the owner creates their own profile' +); +select results_eq( + $$select avatar_url from profiles$$, + array['owner.png'], + 'the owner reads their own profile' +); +select results_eq( + $$update profiles set avatar_url = 'updated.png' returning avatar_url$$, + array['updated.png'], + 'the owner updates their own profile' +); + +-- The with check clause rejects the row, which raises. +select throws_ok( + $$insert into profiles (id, user_id) + values (gen_random_uuid(), '22222222-2222-2222-2222-222222222222')$$, + '42501', + null, + 'the owner cannot create a profile for someone else' +); + +-- A signed-in stranger holds the grant, so the policy is what stops them. The +-- using clause filters the row out, so these match nothing and raise nothing. +set local request.jwt.claim.sub = '22222222-2222-2222-2222-222222222222'; +select is_empty( + $$select * from profiles$$, + 'another user reads no profiles' +); +select is_empty( + $$update profiles set avatar_url = 'stolen.png' returning avatar_url$$, + 'another user updates no profiles' +); +select is_empty( + $$delete from profiles returning id$$, + 'another user deletes no profiles' +); + +-- The row is still there, still holding the owner's value. +set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111'; +select results_eq( + $$select avatar_url from profiles$$, + array['updated.png'], + 'the other user changed nothing' +); +select results_eq( + $$delete from profiles returning avatar_url$$, + array['updated.png'], + 'the owner deletes their own profile' +); + +select * from finish(); +rollback; +``` + +For CLI setup and more pgTAP helpers, see [Testing your database](/docs/guides/database/testing). + ## RLS performance recommendations Every authorization system has an impact on performance. While row level security is powerful, the performance impact is important to keep in mind. This is especially true for queries that scan every row in a table - like many `select` operations, including those using limit, offset, and ordering. @@ -377,7 +564,7 @@ Based on a series of [tests](https://github.com/GaryAustin1/RLS-Performance), we ### Add indexes -Make sure you've added [indexes](/docs/guides/database/postgres/indexes) on any columns used within the Policies which are not already indexed (or primary keys). For a Policy like this: +Add an [index](/docs/guides/database/postgres/indexes) on every column your policies filter on. Postgres evaluates the policy against each candidate row, so an unindexed filter column turns a read into a sequential scan. For a policy like this: ```sql create policy "rls_test_select" on test_table @@ -393,6 +580,21 @@ on test_table using btree (user_id); ``` +A column counts as indexed only when it comes first in a `btree` index. Postgres can't use a multi-column index to filter on a column that isn't the leading one, so a composite primary key indexes its first column and no others. A membership table keyed on `(team_id, user_id)` has no index on `user_id`: + +```sql +create table team_members ( + team_id uuid references teams (id), + user_id uuid references auth.users (id), + primary key (team_id, user_id) +); + +-- The primary key covers team_id. A policy filtering on user_id needs its own index. +create index team_members_user_id_idx +on team_members +using btree (user_id); +``` + #### Benchmarks | Test | Before (ms) | After (ms) | % Improvement | Change | diff --git a/apps/docs/content/guides/monitoring-and-debugging.mdx b/apps/docs/content/guides/monitoring-and-debugging.mdx index f6b732836aeb9..61e018404a271 100644 --- a/apps/docs/content/guides/monitoring-and-debugging.mdx +++ b/apps/docs/content/guides/monitoring-and-debugging.mdx @@ -4,6 +4,8 @@ title: Monitoring and Debugging Monitor your project, debug errors, and understand what's happening across the Supabase stack. +Debugging with an AI agent? See [Debug with AI tools](/docs/guides/monitoring-and-debugging/debugging#debug-with-ai-tools) for the MCP tools and agent skill that let it read your logs and advisors. + diff --git a/apps/docs/content/guides/monitoring-and-debugging/debugging.mdx b/apps/docs/content/guides/monitoring-and-debugging/debugging.mdx index 0c84be99fce20..0c820e038f45b 100644 --- a/apps/docs/content/guides/monitoring-and-debugging/debugging.mdx +++ b/apps/docs/content/guides/monitoring-and-debugging/debugging.mdx @@ -6,6 +6,17 @@ description: 'Isolate and fix Supabase issues by reading the error, isolating th Debug by evidence, not by guessing. A Supabase error almost always surfaces at one layer but originates at another, so the fastest path to a fix is finding _where_ the problem is, not pattern-matching the symptom. Retrying a failed request rarely helps; isolating the layer does. +## Debug with AI tools + +An AI agent can work through this loop for you, but only if it can read your project's evidence instead of guessing from the error message. + +Debugging with an agent needs two things: + +- The [Supabase MCP server](/docs/guides/ai-tools/mcp) provides the tools this guide relies on: `get_logs` for a per-service log dump, `query_logs` to run read-only SQL against your logs for filtering and aggregation, `get_advisors` for security and performance findings, and `execute_sql` to inspect your schema and policies. +- The [Supabase agent skill](/docs/guides/ai-tools/ai-skills) teaches the agent this workflow: locate the failing layer, gather evidence from the matching log source, and verify the fix by re-running the operation that failed. + +Install both in one step with the [Supabase plugin for AI coding agents](/docs/guides/ai-tools/plugins). Connecting an agent to your project carries security risks, so read the [MCP security best practices](/docs/guides/ai-tools/mcp#security-risks) first. + ## Follow these debugging steps Work through these steps in order, skipping straight to a fix before you have evidence for the cause is the most common way to waste time on a bug. diff --git a/apps/docs/content/guides/monitoring-and-debugging/log-drains.mdx b/apps/docs/content/guides/monitoring-and-debugging/log-drains.mdx index 8ca351c8bbde2..21216bba6fba5 100644 --- a/apps/docs/content/guides/monitoring-and-debugging/log-drains.mdx +++ b/apps/docs/content/guides/monitoring-and-debugging/log-drains.mdx @@ -191,7 +191,7 @@ Logs are batched and sent to Datadog with Gzip compression. Each event's log sou **Required configuration:** - API Key — from [Datadog Organization Settings](https://app.datadoghq.com/organization-settings/api-keys) -- Region — the Datadog site your account uses (US1, US3, US5, EU, AP1, AP2, US1-FED) +- Region — the Datadog site your account uses (US1, US3, US5, EU, AP1, AP2, UK1, US1-FED, US2-FED) **Steps:** diff --git a/apps/docs/content/guides/self-hosting/self-hosted-envoy.mdx b/apps/docs/content/guides/self-hosting/self-hosted-envoy.mdx index 150cb4a7c5488..f4369c1fd3d47 100644 --- a/apps/docs/content/guides/self-hosting/self-hosted-envoy.mdx +++ b/apps/docs/content/guides/self-hosting/self-hosted-envoy.mdx @@ -199,6 +199,8 @@ The gateway applies a permissive CORS policy at the virtual-host level: This matches both the current Supabase platform behavior and the previous Kong-based gateway. The auth boundary for Supabase APIs is the `apikey` header rather than the request origin. +The `/pg/` route (direct Postgres introspection via `meta`, gated by the service role key) is the exception: it carries its own stricter per-route CORS override, restricted to `SUPABASE_PUBLIC_URL` and `localhost`/`127.0.0.1` origins, since there's no legitimate reason for this route to be called from an arbitrary third-party origin. + If you customize the `cors:` block in `lds.template.yaml` to enable `allow_credentials: true`, you must restrict `allow_origin_string_match` to specific origins - browsers (and Envoy) reject the combination of credentials with a wildcard origin. diff --git a/apps/studio/components/interfaces/LogDrains/LogDrains.constants.tsx b/apps/studio/components/interfaces/LogDrains/LogDrains.constants.tsx index c3e0e7065699e..5a10b1f2a71e1 100644 --- a/apps/studio/components/interfaces/LogDrains/LogDrains.constants.tsx +++ b/apps/studio/components/interfaces/LogDrains/LogDrains.constants.tsx @@ -85,6 +85,10 @@ export const DATADOG_REGIONS = [ label: 'EU', value: 'EU', }, + { + label: 'UK1', + value: 'UK1', + }, { label: 'US1', value: 'US1', @@ -93,6 +97,10 @@ export const DATADOG_REGIONS = [ label: 'US1-FED', value: 'US1-FED', }, + { + label: 'US2-FED', + value: 'US2-FED', + }, { label: 'US3', value: 'US3', diff --git a/apps/studio/data/content/notebooks/notebook-operations.test.ts b/apps/studio/data/content/notebooks/notebook-operations.test.ts index 3fafc6f74310f..e77b1d6b56b59 100644 --- a/apps/studio/data/content/notebooks/notebook-operations.test.ts +++ b/apps/studio/data/content/notebooks/notebook-operations.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' -import { applyNotebookOperations, type NotebookOperation } from './notebook-operations' +import { + applyNotebookOperations, + deriveNotebookDiff, + type NotebookOperation, +} from './notebook-operations' import type { NotebookWire } from './notebook-schema' const NOTEBOOK: NotebookWire = { @@ -256,4 +260,262 @@ describe('applyNotebookOperations', () => { expect(result).toEqual({ success: false, error: { _tag: 'empty_result' } }) }) + + it('anchors an insert on a cell that an earlier operation replaced', () => { + const ops: NotebookOperation[] = [ + { _tag: 'replace_cell', cell_id: 'cell-2', cell: NEW_MARKDOWN_CELL }, + { _tag: 'insert_cell', after_cell_id: 'cell-2', cell: { _tag: 'markdown_cell', text: 'x' } }, + ] + + const result = applyNotebookOperations(NOTEBOOK, ops) + + expect(result).toEqual({ + success: true, + notebook: { + schema_version: 1, + cells: [ + NOTEBOOK.cells[0], + NEW_MARKDOWN_CELL, + { _tag: 'markdown_cell', text: 'x' }, + NOTEBOOK.cells[2], + ], + }, + }) + }) + + it('anchors a move on a cell that an earlier operation replaced', () => { + const ops: NotebookOperation[] = [ + { _tag: 'replace_cell', cell_id: 'cell-2', cell: NEW_MARKDOWN_CELL }, + { _tag: 'move_cell', cell_id: 'cell-3', after_cell_id: 'cell-2' }, + ] + + const result = applyNotebookOperations(NOTEBOOK, ops) + + expect(result).toEqual({ + success: true, + notebook: { + schema_version: 1, + cells: [NOTEBOOK.cells[0], NEW_MARKDOWN_CELL, NOTEBOOK.cells[2]], + }, + }) + }) + + it('still rejects an operation targeting a cell that an earlier operation replaced', () => { + // A replaced cell can be anchored on, but not targeted — the conflict pre-pass rejects + // the batch before any operation runs. + const ops: NotebookOperation[] = [ + { _tag: 'replace_cell', cell_id: 'cell-2', cell: NEW_MARKDOWN_CELL }, + { _tag: 'delete_cell', cell_id: 'cell-2' }, + ] + + const result = applyNotebookOperations(NOTEBOOK, ops) + + expect(result).toEqual({ + success: false, + error: { _tag: 'conflicting_operations', cell_id: 'cell-2' }, + }) + }) +}) + +describe('deriveNotebookDiff', () => { + it('annotates every cell as unchanged when there are no operations', () => { + const result = deriveNotebookDiff(NOTEBOOK, []) + + expect(result).toEqual({ + success: true, + entries: [ + { _tag: 'unchanged', cell: NOTEBOOK.cells[0] }, + { _tag: 'unchanged', cell: NOTEBOOK.cells[1] }, + { _tag: 'unchanged', cell: NOTEBOOK.cells[2] }, + ], + }) + }) + + it('annotates an inserted cell as added in its resulting position', () => { + const ops: NotebookOperation[] = [ + { _tag: 'insert_cell', after_cell_id: 'cell-1', cell: NEW_MARKDOWN_CELL }, + ] + + const result = deriveNotebookDiff(NOTEBOOK, ops) + + expect(result).toEqual({ + success: true, + entries: [ + { _tag: 'unchanged', cell: NOTEBOOK.cells[0] }, + { _tag: 'added', cell: NEW_MARKDOWN_CELL, operationIndex: 0 }, + { _tag: 'unchanged', cell: NOTEBOOK.cells[1] }, + { _tag: 'unchanged', cell: NOTEBOOK.cells[2] }, + ], + }) + }) + + it('annotates a replaced cell in place, keeping both sides', () => { + const ops: NotebookOperation[] = [ + { _tag: 'replace_cell', cell_id: 'cell-2', cell: NEW_MARKDOWN_CELL }, + ] + + const result = deriveNotebookDiff(NOTEBOOK, ops) + + expect(result).toEqual({ + success: true, + entries: [ + { _tag: 'unchanged', cell: NOTEBOOK.cells[0] }, + { + _tag: 'replaced', + before: NOTEBOOK.cells[1], + after: NEW_MARKDOWN_CELL, + operationIndex: 0, + }, + { _tag: 'unchanged', cell: NOTEBOOK.cells[2] }, + ], + }) + }) + + it('keeps a deleted cell in the position it used to hold', () => { + const ops: NotebookOperation[] = [{ _tag: 'delete_cell', cell_id: 'cell-2' }] + + const result = deriveNotebookDiff(NOTEBOOK, ops) + + expect(result).toEqual({ + success: true, + entries: [ + { _tag: 'unchanged', cell: NOTEBOOK.cells[0] }, + { _tag: 'removed', cell: NOTEBOOK.cells[1], operationIndex: 0 }, + { _tag: 'unchanged', cell: NOTEBOOK.cells[2] }, + ], + }) + }) + + it('annotates a moved cell with the position it started from', () => { + const ops: NotebookOperation[] = [ + { _tag: 'move_cell', cell_id: 'cell-1', after_cell_id: 'cell-3' }, + ] + + const result = deriveNotebookDiff(NOTEBOOK, ops) + + expect(result).toEqual({ + success: true, + entries: [ + { _tag: 'unchanged', cell: NOTEBOOK.cells[1] }, + { _tag: 'unchanged', cell: NOTEBOOK.cells[2] }, + { _tag: 'moved', cell: NOTEBOOK.cells[0], fromIndex: 0, operationIndex: 0 }, + ], + }) + }) + + it('reports fromIndex against the original notebook, not the shifted working order', () => { + const ops: NotebookOperation[] = [ + { _tag: 'delete_cell', cell_id: 'cell-1' }, + { _tag: 'move_cell', cell_id: 'cell-3', after_cell_id: 'start' }, + ] + + const result = deriveNotebookDiff(NOTEBOOK, ops) + + expect(result).toEqual({ + success: true, + entries: [ + { _tag: 'moved', cell: NOTEBOOK.cells[2], fromIndex: 2, operationIndex: 1 }, + { _tag: 'removed', cell: NOTEBOOK.cells[0], operationIndex: 0 }, + { _tag: 'unchanged', cell: NOTEBOOK.cells[1] }, + ], + }) + }) + + it('downgrades moves that cancel out to unchanged', () => { + // Same operations as the applyNotebookOperations case that lands back on the original + // order: nothing actually moved, so nothing should be badged as moved. + const ops: NotebookOperation[] = [ + { _tag: 'move_cell', cell_id: 'cell-1', after_cell_id: 'cell-2' }, + { _tag: 'move_cell', cell_id: 'cell-2', after_cell_id: 'cell-1' }, + ] + + const result = deriveNotebookDiff(NOTEBOOK, ops) + + expect(result).toEqual({ + success: true, + entries: [ + { _tag: 'unchanged', cell: NOTEBOOK.cells[0] }, + { _tag: 'unchanged', cell: NOTEBOOK.cells[1] }, + { _tag: 'unchanged', cell: NOTEBOOK.cells[2] }, + ], + }) + }) + + it('keeps reporting moves that do change the order', () => { + const ops: NotebookOperation[] = [ + { _tag: 'move_cell', cell_id: 'cell-1', after_cell_id: 'cell-3' }, + { _tag: 'move_cell', cell_id: 'cell-2', after_cell_id: 'cell-1' }, + ] + + const result = deriveNotebookDiff(NOTEBOOK, ops) + + expect(result.success).toBe(true) + if (!result.success) return + expect(result.entries.map((entry) => entry._tag)).toEqual(['unchanged', 'moved', 'moved']) + }) + + it('leaves removed entries out of the way of inserts anchored at the same cell', () => { + const ops: NotebookOperation[] = [ + { _tag: 'delete_cell', cell_id: 'cell-2' }, + { + _tag: 'insert_cell', + after_cell_id: 'cell-1', + cell: { _tag: 'markdown_cell', text: '1st' }, + }, + { + _tag: 'insert_cell', + after_cell_id: 'cell-1', + cell: { _tag: 'markdown_cell', text: '2nd' }, + }, + ] + + const result = deriveNotebookDiff(NOTEBOOK, ops) + + expect(result.success).toBe(true) + if (!result.success) return + expect(result.entries.map((entry) => entry._tag)).toEqual([ + 'unchanged', + 'added', + 'added', + 'removed', + 'unchanged', + ]) + // The removed entry sits in the middle of the entry list but must not affect the order + // the surviving cells end up in. + expect(applyNotebookOperations(NOTEBOOK, ops)).toEqual({ + success: true, + notebook: { + schema_version: 1, + cells: [ + NOTEBOOK.cells[0], + { _tag: 'markdown_cell', text: '1st' }, + { _tag: 'markdown_cell', text: '2nd' }, + NOTEBOOK.cells[2], + ], + }, + }) + }) + + it('reports the same errors as applyNotebookOperations', () => { + expect(deriveNotebookDiff(NOTEBOOK, [{ _tag: 'delete_cell', cell_id: 'missing' }])).toEqual({ + success: false, + error: { _tag: 'unknown_cell_id', cell_id: 'missing' }, + }) + expect( + deriveNotebookDiff(NOTEBOOK, [ + { _tag: 'delete_cell', cell_id: 'cell-2' }, + { _tag: 'replace_cell', cell_id: 'cell-2', cell: NEW_MARKDOWN_CELL }, + ]) + ).toEqual({ + success: false, + error: { _tag: 'conflicting_operations', cell_id: 'cell-2' }, + }) + expect( + deriveNotebookDiff(NOTEBOOK, [ + { _tag: 'delete_cell', cell_id: 'cell-1' }, + { _tag: 'delete_cell', cell_id: 'cell-2' }, + { _tag: 'delete_cell', cell_id: 'cell-3' }, + ]) + ).toEqual({ success: false, error: { _tag: 'empty_result' } }) + }) }) diff --git a/apps/studio/data/content/notebooks/notebook-operations.ts b/apps/studio/data/content/notebooks/notebook-operations.ts index 8ff279ac980d3..2e4391e07ddba 100644 --- a/apps/studio/data/content/notebooks/notebook-operations.ts +++ b/apps/studio/data/content/notebooks/notebook-operations.ts @@ -67,6 +67,22 @@ export type ApplyNotebookOperationsResult = | { success: true; notebook: NotebookOperationsResult } | { success: false; error: NotebookOperationError } +export type NotebookCellDiffEntry = + | { _tag: 'unchanged'; cell: CellWire } + | { _tag: 'added'; cell: AgentCell; operationIndex: number } + | { _tag: 'removed'; cell: CellWire; operationIndex: number } + | { _tag: 'replaced'; before: CellWire; after: AgentCell; operationIndex: number } + | { + _tag: 'moved' + cell: CellWire + fromIndex: number + operationIndex: number + } + +export type DeriveNotebookDiffResult = + | { success: true; entries: NotebookCellDiffEntry[] } + | { success: false; error: NotebookOperationError } + export function describeNotebookOperationError(error: NotebookOperationError): string { switch (error._tag) { case 'unknown_cell_id': @@ -89,10 +105,67 @@ function targetCellId(operation: NotebookOperation): string | undefined { } } -export function applyNotebookOperations( +function findAnchorIndex(entries: NotebookCellDiffEntry[], cellId: string): number { + return entries.findIndex((entry) => { + switch (entry._tag) { + case 'unchanged': + case 'moved': + return entry.cell.id === cellId + case 'replaced': + return entry.before.id === cellId + case 'added': + case 'removed': + return false + } + }) +} + +function findTargetCell( + entries: NotebookCellDiffEntry[], + cellId: string +): { index: number; cell: CellWire } | undefined { + for (let index = 0; index < entries.length; index++) { + const entry = entries[index] + if (entry._tag !== 'unchanged' && entry._tag !== 'moved') continue + if (entry.cell.id === cellId) return { index, cell: entry.cell } + } + return undefined +} + +function downgradeNoOpMoves( + entries: NotebookCellDiffEntry[], + notebook: NotebookWire +): NotebookCellDiffEntry[] { + if (!entries.some((entry) => entry._tag === 'moved')) return entries + + const finalOrder = entries.flatMap((entry) => + entry._tag === 'unchanged' || entry._tag === 'moved' ? [entry.cell.id] : [] + ) + const survivingIds = new Set(finalOrder) + const originalOrder = notebook.cells + .map((cell) => cell.id) + .filter((cellId) => survivingIds.has(cellId)) + + const hasSamePredecessors = (cellId: string) => { + const finalPredecessors = new Set(finalOrder.slice(0, finalOrder.indexOf(cellId))) + const originalPredecessors = originalOrder.slice(0, originalOrder.indexOf(cellId)) + return ( + originalPredecessors.length === finalPredecessors.size && + originalPredecessors.every((predecessor) => finalPredecessors.has(predecessor)) + ) + } + + return entries.map((entry) => + entry._tag === 'moved' && hasSamePredecessors(entry.cell.id) + ? { _tag: 'unchanged', cell: entry.cell } + : entry + ) +} + +export function deriveNotebookDiff( notebook: NotebookWire, operations: NotebookOperation[] -): ApplyNotebookOperationsResult { +): DeriveNotebookDiffResult { const targetedIds = new Set() for (const operation of operations) { const cellId = targetCellId(operation) @@ -104,53 +177,66 @@ export function applyNotebookOperations( targetedIds.add(cellId) } - const cells: OperationResultCell[] = [...notebook.cells] - const indexOfCellId = (cellId: string) => - cells.findIndex((cell) => 'id' in cell && cell.id === cellId) + const originalIndexById = new Map(notebook.cells.map((cell, index) => [cell.id, index])) + const entries: NotebookCellDiffEntry[] = notebook.cells.map((cell) => ({ + _tag: 'unchanged', + cell, + })) const insertedAfter = new Map() const insertAfter = ( anchor: string, - item: OperationResultCell + entry: NotebookCellDiffEntry ): NotebookOperationError | undefined => { - const anchorIndex = anchor === CELL_ANCHOR_START ? -1 : indexOfCellId(anchor) + const anchorIndex = anchor === CELL_ANCHOR_START ? -1 : findAnchorIndex(entries, anchor) if (anchor !== CELL_ANCHOR_START && anchorIndex === -1) { return { _tag: 'unknown_cell_id', cell_id: anchor } } const offset = insertedAfter.get(anchor) ?? 0 - cells.splice(anchorIndex + 1 + offset, 0, item) + entries.splice(anchorIndex + 1 + offset, 0, entry) insertedAfter.set(anchor, offset + 1) return undefined } - for (const operation of operations) { + for (let operationIndex = 0; operationIndex < operations.length; operationIndex++) { + const operation = operations[operationIndex] + switch (operation._tag) { case 'insert_cell': { - const error = insertAfter(operation.after_cell_id, operation.cell) + const error = insertAfter(operation.after_cell_id, { + _tag: 'added', + cell: operation.cell, + operationIndex, + }) if (error) return { success: false, error } break } case 'replace_cell': { - const index = indexOfCellId(operation.cell_id) - if (index === -1) { + const found = findTargetCell(entries, operation.cell_id) + if (found === undefined) { return { success: false, error: { _tag: 'unknown_cell_id', cell_id: operation.cell_id }, } } - cells[index] = operation.cell + entries[found.index] = { + _tag: 'replaced', + before: found.cell, + after: operation.cell, + operationIndex, + } break } case 'delete_cell': { - const index = indexOfCellId(operation.cell_id) - if (index === -1) { + const found = findTargetCell(entries, operation.cell_id) + if (found === undefined) { return { success: false, error: { _tag: 'unknown_cell_id', cell_id: operation.cell_id }, } } - cells.splice(index, 1) + entries[found.index] = { _tag: 'removed', cell: found.cell, operationIndex } break } case 'move_cell': { @@ -161,28 +247,61 @@ export function applyNotebookOperations( } } - const fromIndex = indexOfCellId(operation.cell_id) - if (fromIndex === -1) { + const found = findTargetCell(entries, operation.cell_id) + if (found === undefined) { return { success: false, error: { _tag: 'unknown_cell_id', cell_id: operation.cell_id }, } } - const [movedCell] = cells.splice(fromIndex, 1) - const error = insertAfter(operation.after_cell_id, movedCell) + entries.splice(found.index, 1) + const error = insertAfter(operation.after_cell_id, { + _tag: 'moved', + cell: found.cell, + fromIndex: originalIndexById.get(found.cell.id) ?? found.index, + operationIndex, + }) if (error) return { success: false, error } break } } } - if (cells.length === 0) { + if (!entries.some((entry) => entry._tag !== 'removed')) { return { success: false, error: { _tag: 'empty_result' } } } + return { success: true, entries: downgradeNoOpMoves(entries, notebook) } +} + +function resultingCells(entries: NotebookCellDiffEntry[]): OperationResultCell[] { + return entries.flatMap((entry) => { + switch (entry._tag) { + case 'unchanged': + case 'moved': + case 'added': + return [entry.cell] + case 'replaced': + return [entry.after] + case 'removed': + return [] + } + }) +} + +export function applyNotebookOperations( + notebook: NotebookWire, + operations: NotebookOperation[] +): ApplyNotebookOperationsResult { + const result = deriveNotebookDiff(notebook, operations) + if (!result.success) return result + return { success: true, - notebook: { schema_version: notebook.schema_version, cells }, + notebook: { + schema_version: notebook.schema_version, + cells: resultingCells(result.entries), + }, } } diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 33972e9b13691..d05807667aa63 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -101,6 +101,7 @@ services: SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY:-} ANON_KEY_ASYMMETRIC: ${ANON_KEY_ASYMMETRIC:-} SERVICE_ROLE_KEY_ASYMMETRIC: ${SERVICE_ROLE_KEY_ASYMMETRIC:-} + SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL} DASHBOARD_USERNAME: ${DASHBOARD_USERNAME} DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD} entrypoint: ["/bin/sh", "/docker-entrypoint.sh"] diff --git a/docker/volumes/api/envoy/docker-entrypoint.sh b/docker/volumes/api/envoy/docker-entrypoint.sh index 7836038590fac..2c67584a30e15 100755 --- a/docker/volumes/api/envoy/docker-entrypoint.sh +++ b/docker/volumes/api/envoy/docker-entrypoint.sh @@ -15,6 +15,7 @@ sed -e "s|\${ANON_KEY}|${ANON_KEY}|g" \ -e "s|\${SERVICE_ROLE_KEY_ASYMMETRIC}|${SERVICE_ROLE_KEY_ASYMMETRIC}|g" \ -e "s|\${SUPABASE_PUBLISHABLE_KEY}|${SUPABASE_PUBLISHABLE_KEY}|g" \ -e "s|\${SUPABASE_SECRET_KEY}|${SUPABASE_SECRET_KEY}|g" \ + -e "s|\${SUPABASE_PUBLIC_URL}|${SUPABASE_PUBLIC_URL}|g" \ -e "s|\${DASHBOARD_BASIC_AUTH}|${DASHBOARD_BASIC_AUTH}|g" \ /etc/envoy/lds.template.yaml > /etc/envoy/lds.yaml diff --git a/docker/volumes/api/envoy/lds.template.yaml b/docker/volumes/api/envoy/lds.template.yaml index d6f5f314a9b75..1a3c345188617 100644 --- a/docker/volumes/api/envoy/lds.template.yaml +++ b/docker/volumes/api/envoy/lds.template.yaml @@ -523,6 +523,17 @@ resources: '@type': >- type.googleapis.com/envoy.config.route.v3.FilterConfig disabled: true + envoy.filters.http.cors: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.cors.v3.CorsPolicy + allow_origin_string_match: + - exact: '${SUPABASE_PUBLIC_URL}' + - safe_regex: + regex: 'https?://(localhost|127\.0\.0\.1)(:[0-9]+)?' + allow_methods: "GET,POST,PUT,PATCH,DELETE,OPTIONS,HEAD" + allow_headers: "*" + expose_headers: "*" + max_age: "3600" - match: prefix: /api/mcp