From 7c46793a3f939a35cf7326b02942f2c70c1fc7b6 Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:22:49 +0200 Subject: [PATCH 1/5] docs: mention MCP debugging tools and Supabase agent skill in debugging docs (#48978) ## What - Adds a **Debug with AI tools** section to the debugging guide, covering the MCP debugging tools (`get_logs`, `query_logs`, `get_advisors`, `execute_sql`), the Supabase agent skill, and the combined plugin install, with a pointer to the MCP security best practices. - Adds a one-line pointer to it from the Monitoring and Debugging overview. - Adds the missing `query_logs` entry to the MCP server's Debugging tool group. Note: `pnpm lint:mdx` couldn't run locally (Node version), Prettier passes. ## Summary by CodeRabbit * **Documentation** * Added guidance for debugging with AI tools, including MCP tools and the Supabase agent skill for reading logs and advisors. * Documented plugin installation and security considerations when connecting AI agents through MCP. * Added links from monitoring and debugging guidance to the new AI tools documentation. --------- Co-authored-by: Miranda Limonczenko --- apps/docs/content/guides/monitoring-and-debugging.mdx | 2 ++ .../guides/monitoring-and-debugging/debugging.mdx | 11 +++++++++++ 2 files changed, 13 insertions(+) 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. From 2e68f2bf6ec7ed5a51662c6c38c63436fdc6f488 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:11:40 -0400 Subject: [PATCH 2/5] refactor(studio): derive notebook diff entries (#49109) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Refactor, plus one bug fix. Groundwork for showing the user a preview of what they are approving when the AI Assistant creates or edits a notebook. No UI in this PR. Towards FE-4143 ## What is the current behavior? `applyNotebookOperations` resolves an ordered list of notebook operations into the resulting cells and nothing else. Rendering a diff for the approval gate needs to know *what happened* to each cell position, not just where things landed, so there is no way to build the preview on top of it. Separately, replacing a cell dropped its id, so `[replace cell-2, insert after cell-2]` failed with a spurious `unknown_cell_id`. ## What is the new behavior? `deriveNotebookDiff` resolves operations into one annotated entry per cell position (`unchanged`, `added`, `removed`, `replaced`, `moved`). `applyNotebookOperations` becomes a thin projection over its result, so there is a single interpreter of notebook operations and the diff a user approves cannot disagree with the cells that get written. The pre-existing tests pass untouched, which is the evidence that the projection is faithful. Notes on the annotations: - `removed` entries stay in the position the cell used to hold so the list reads as a diff. This does not perturb insert-anchor arithmetic: prior inserts still sit contiguously after their anchor. - Moves that cancel out are downgraded to `unchanged`, since two moves can anchor on each other and leave every cell where it started. Badging those as moved would make the preview lie. - `fromIndex` is the cell's position in the original notebook rather than in the shifted working order, so `was #3` means what a reader expects. A replaced cell now stays addressable as an anchor. Anchoring and targeting are separate lookups: a replaced cell can be anchored on, but is never a legitimate target. ## Summary by CodeRabbit * **New Features** * Notebook changes now provide a structured view of added, removed, replaced, moved, and unchanged cells. * Replaced cells can be used as insertion anchors, while invalid or duplicate targets are rejected. * No-op moves are handled as unchanged cells. * Notebook edits preserve operation ordering and original cell positions for more predictable results. * **Bug Fixes** * Improved notebook operation handling and error reporting for complex cell edits. --- .../notebooks/notebook-operations.test.ts | 264 +++++++++++++++++- .../content/notebooks/notebook-operations.ts | 163 +++++++++-- 2 files changed, 404 insertions(+), 23 deletions(-) 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), + }, } } From 04ddc6bef8bc585f2fdcdb42c502200a4d6c1782 Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Mon, 17 Aug 2026 19:28:09 +0200 Subject: [PATCH 3/5] chore: update cors for pg routes (#49136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Bug fix - config hardening ## What is the current behavior? CORS is applied at the global level in a permissive mode ## What is the new behavior? Self-hosted envoy config should apply CORS to the `/pg` routes. These should only be called from the studio dashboard (when called via a browser). uses `SUPABASE_PUBLIC_URL`, which should mean this isn't a breaking change. ## Summary by CodeRabbit * **Security & Access** * Added stricter CORS controls for the `/pg/` route. * Requests are limited to the configured public URL and localhost origins. * Standard HTTP methods and headers are supported, with preflight responses cached for one hour. * **Documentation** * Updated self-hosting guidance to describe the `/pg/` route’s CORS policy. --- .../content/guides/self-hosting/self-hosted-envoy.mdx | 2 ++ docker/docker-compose.yml | 1 + docker/volumes/api/envoy/docker-entrypoint.sh | 1 + docker/volumes/api/envoy/lds.template.yaml | 11 +++++++++++ 4 files changed, 15 insertions(+) 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/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 From ff6c8d4b30326fe5476a697cf6df4209965449b8 Mon Sep 17 00:00:00 2001 From: David Whittington Date: Mon, 17 Aug 2026 15:22:13 -0500 Subject: [PATCH 4/5] fix(log-drains): add UK1 and US2-FED Datadog regions (#49156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add `UK1` and `US2-FED` to the Datadog region dropdown in the log drains studio UI - Add the same two regions to the Datadog region list in the log-drains docs page The Logflare backend added support for these two Datadog regions in [Logflare/logflare#3790](https://github.com/Logflare/logflare/pull/3790) (shipped in v1.50.1), but the studio dropdown and docs were never updated, so customers on UK1 or US2-FED couldn't actually select their region when setting up a Datadog log drain. ## Test plan - [ ] Open Project Settings → Log Drains → add a Datadog destination and confirm UK1 and US2-FED appear in the Region dropdown - [ ] Confirm a log drain configured with `UK1`/`US2-FED` saves and sends events successfully ## Summary by CodeRabbit * **New Features** * Added support for configuring Datadog log drains in the UK1 and US2-FED regions. * **Documentation** * Updated the monitoring and debugging guide with the UK1 Datadog region. --- .../guides/monitoring-and-debugging/log-drains.mdx | 2 +- .../interfaces/LogDrains/LogDrains.constants.tsx | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) 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/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', From d2ccbe5d46eda8ac96f59affb59295d4a5ede310 Mon Sep 17 00:00:00 2001 From: Miranda Limonczenko Date: Mon, 17 Aug 2026 15:05:39 -0700 Subject: [PATCH 5/5] docs(database): close the RLS guide gaps the eval flagged (#49011) Closes DOCS-1274 ## Problem The `build-docs-002-rls-guide` eval points an agent at the Row Level Security guide with a vibe-coder prompt that never says RLS, policy, role, or test. It failed 6 of 35 checks. Each failure traces to something the guide doesn't say. - **Grants.** `anon` kept insert, update, and delete on all four to-do tables. Both client roles kept writes on the weather feed. 24 privileges untouched. - **Indexes.** Missing on `list_members.user_id`. The agent indexed the other three, so it missed the composite-primary-key case specifically. - **Tests.** No pgTAP files. `Result: NOTESTS`, so the coverage judge never ran. ## Solution - **Add a `Grants and policies` section.** - **Rewrite the opening danger admonition around revoke-then-grant.** It previously showed `grant` only, which reads as though privileges start from nothing. - **Drop the `(or primary keys)` carve-out from `Add indexes`.** A column counts as indexed only when it leads a `btree` index, shown with a composite-primary-key example. - **Add a `Test your policies` section.** Covers file location under `supabase/tests/`, `supabase test db`, role and identity switching, which assertion matches which denial, and an 11-assertion example spanning allow and deny for all four operations across `anon` and `authenticated`. Used the supacademy RLS course as a second reference. Its framing of grants running before RLS shaped the new section. ## Manual testing 1. Open the [Row Level Security guide](https://docs-git-docs-rls-revision-supabase.vercel.app/docs/guides/database/postgres/row-level-security) on the preview. `Grants and policies` and `Test your policies` appear in the table of contents. 2. Select the `Grants and policies` link at the end of the first admonition. It jumps to the new section. 3. Open the [markdown version](https://docs-git-docs-rls-revision-supabase.vercel.app/docs/guides/database/postgres/row-level-security.md), which is what agents fetch. Both new sections and the revised `Add indexes` text are present. 4. From `apps/docs`, run `pnpm lint:mdx`. The 4 warnings on this file match `master`, with no new ones. ## Summary by CodeRabbit ## Documentation * Clarified that exposed tables must enable row-level security. * Explained the distinction between database grants and row-level security policies. * Added least-privilege examples for client roles, including read-only access. * Added pgTAP testing guidance with a complete `profiles` example. * Clarified that composite indexes support policy filters only on their leading columns. --------- Co-authored-by: Claude Opus 5 --- .../database/postgres/row-level-security.mdx | 218 +++++++++++++++++- 1 file changed, 210 insertions(+), 8 deletions(-) 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 |