Merge branch 'master' into feature/www-composer

This commit is contained in:
Saxon Fletcher committed 2026-06-04 12:23:20 +10:00
commit 9b46d1c84e
1017 files changed
+60653 -947171

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@ pnpm 10 + Turborepo monorepo. Requires Node >= 22.
| Directory | Purpose |
| ----------------- | ------------------------------------------------------------ |
| `apps/studio` | Supabase Studio/Dashboard — Next.js (pages router), React 18 |
| `apps/studio` | Supabase Studio/Dashboard — Next.js (pages router), React 19 |
| `apps/docs` | Documentation site |
| `apps/www` | Marketing website |
| `packages/ui` | Shared UI components (shadcn/ui based) |
+67 -5
View File
@@ -279,7 +279,7 @@ function MyBadComponent() {
### Round-tripping SQL from the database (NOT snippet content)
```ts
// ✅ GOOD: SQL from the database is promoted to SafeSqlFragment at the point
// ✅ GOOD: SQL from the database is promoted to SafeSqlFragment at the point
// of fetching
// data/function-definitions.ts
@@ -323,10 +323,10 @@ function MyComponent() {
### Snippet content is ALWAYS UNSAFE
Snippets are auto-persisted to the database and can be created or modified
through externally influenceable channels (e.g., prefilled from URL params).
The `unchecked_sql` property is typed as `UntrustedSqlFragment` to enforce this
— it must only be promoted to `SafeSqlFragment` via `acceptUntrustedSql` in an
Snippets are auto-persisted to the database and can be created or modified
through externally influenceable channels (e.g., prefilled from URL params).
The `unchecked_sql` property is typed as `UntrustedSqlFragment` to enforce this
— it must only be promoted to `SafeSqlFragment` via `acceptUntrustedSql` in an
event handler that requires explicit user action.
```ts
@@ -376,3 +376,65 @@ function SnippetRunner({ snippet }: { snippet: Snippet }) {
)
}
```
## Analytics SQL (BigQuery / ClickHouse)
The same security model applies to analytics queries, which target BigQuery
or ClickHouse via the
`/platform/projects/{ref}/analytics/endpoints/logs.all{,.otel}` endpoints.
Filter keys and values from URL parameters and UI inputs are spliced into SQL
that runs against the project's logs, so the same injection risk exists.
The brand and helpers live in `apps/studio/data/logs/safe-analytics-sql.ts`,
intentionally **disjoint** from the pg-meta `SafeSqlFragment` brand:
- `SafeLogSqlFragment` — branded type for analytics SQL.
- `safeSql` — template tag that only accepts `SafeLogSqlFragment`
interpolations.
- `analyticsLiteral(value)` — sanitizes string/number/boolean literals.
- `quotedIdent(name)` — validates and backtick-quotes dotted identifiers.
- `keyword(value, allowed)` — validates against an allow-list of operators.
- `joinSqlFragments(fragments, separator)` — composes already-branded
fragments.
The brands are kept separate because escape semantics differ — Postgres-safe
`E'…'` strings, `::jsonb` casts, and double-quoted identifiers are unsafe for
BigQuery and/or ClickHouse, and vice versa. Crossing the brands would silently
emit unsafe SQL.
The wire-boundary wrapper is `executeAnalyticsSql` in
`apps/studio/data/logs/execute-analytics-sql.ts`, analogous to pg-meta's
`executeSql`. It accepts only `SafeLogSqlFragment` for its `sql` parameter, so
raw strings are rejected at compile time. A grep-based vitest
(`apps/studio/tests/unit/lints/analytics-sql-boundary.test.ts`) prevents
regressions by failing the build if any file outside
`execute-analytics-sql.ts` calls `post()` or `get()` directly against
`logs.all` or `logs.all.otel`.
```ts
import { executeAnalyticsSql } from '@/data/logs/execute-analytics-sql'
import { analyticsLiteral, quotedIdent, safeSql } from '@/data/logs/safe-analytics-sql'
// ✅ GOOD: every interpolation is sanitized.
const sql = safeSql`
SELECT timestamp, event_message
FROM ${quotedIdent(table)}
WHERE id = ${analyticsLiteral(id)}
`
await executeAnalyticsSql({
projectRef,
endpoint: '/platform/projects/{ref}/analytics/endpoints/logs.all',
sql,
iso_timestamp_start,
iso_timestamp_end,
})
```
```ts
// 🛑 BAD: raw string interpolation. This fails to type-check at the
// executeAnalyticsSql boundary because the result is `string`, not
// `SafeLogSqlFragment`.
const sql = `SELECT * FROM ${table} WHERE id = '${id}'`
await executeAnalyticsSql({ projectRef, endpoint, sql, ... })
```
@@ -0,0 +1,294 @@
---
name: studio-mock-api-tests
description: Component tests for Supabase Studio that mock API requests at the
network layer with MSW. Use when writing or reviewing a component test that
exercises a React Query hook or mutation, or when migrating an existing
test away from vi.mock('@/data/...'). Covers the customRender + addAPIMock
template and the jsdom/MSW gotchas that cost real debugging time.
---
# Studio MSW component tests
Mount a Studio component, intercept its network calls with MSW, assert
what renders and what gets sent. The infrastructure is already wired up —
this skill is the working template plus the gotchas.
## When to use
- The component (or any descendant it renders) calls a React Query hook
or mutation that hits `/platform/...`, `/v1/...`, or another endpoint
in `apps/studio/data/api.d.ts`.
- You'd otherwise be tempted to write `vi.mock('@/data/some-query', ...)`.
**Don't.** Mock the network instead — see "Why not vi.mock" below.
If the component is purely presentational with no data fetching, you
don't need MSW; render and assert directly.
## The template
```tsx
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { mockAnimationsApi } from 'jsdom-testing-mocks'
import { HttpResponse } from 'msw'
import { describe, expect, test, vi } from 'vitest'
import { MyComponent } from './MyComponent'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock } from '@/tests/lib/msw'
// Needed if the component renders inside a Sheet, Modal, Popover, or
// anything else built on Radix that uses Web Animations.
mockAnimationsApi()
describe('MyComponent', () => {
test('renders rows from the API', async () => {
addAPIMock({
method: 'get',
path: '/platform/organizations',
response: () =>
HttpResponse.json<OrganizationResponse[]>([
{
/* ... */
},
]),
})
customRender(<MyComponent />)
expect(await screen.findByText('Acme')).toBeInTheDocument()
})
})
```
That's the whole pattern. Server lifecycle (`listen`/`resetHandlers`/
`close`) is handled by `apps/studio/tests/vitestSetup.ts` — handlers
registered via `addAPIMock` are scoped to the current test.
## Gotchas that will eat your afternoon
### 1. Path params use `:slug`, not `{slug}`
`addAPIMock` is typed from the OpenAPI `paths`, but path params are
remapped to MSW's `:param` format. Autocomplete will guide you, but if
typecheck reports the path isn't assignable, you're using the OpenAPI
`{slug}` form.
```ts
// ❌ TypeScript error, MSW won't match
path: '/platform/organizations/{slug}/projects'
// ✅ Correct
path: '/platform/organizations/:slug/projects'
```
### 2. Use `HttpResponse.json`, not `new HttpResponse`
For success responses, always go through `HttpResponse.json` — even for
204/201-no-content endpoints. A raw `new HttpResponse(null, { status: 201 })`
returns no content-type, and `openapi-fetch` can hang the mutation flow,
which silently breaks `onSuccess` callbacks.
```ts
// ❌ Mutation onSuccess silently never fires
response: () => new HttpResponse(null, { status: 201 })
// ✅ Works (pass the OpenAPI body shape explicitly — see gotcha #8)
response: () => HttpResponse.json<MyResponse>({}, { status: 201 })
```
### 3. Submit buttons in Sheets/Modals need `fireEvent.click`
The convention `<Button form={FORM_ID} htmlType="submit" />` (button
outside the form, associated by id) doesn't reliably trigger submission
under `userEvent.click` in jsdom. Use `fireEvent.click` for the submit
button. Continue to use `userEvent.type` for inputs.
```ts
await userEvent.type(screen.getByPlaceholderText('value'), 'hello')
fireEvent.click(await screen.findByRole('button', { name: 'Save' }))
```
### 4. Profile-gated queries need a `profileContext`
Many hooks (`useOrganizationsQuery`, anything in `data/projects/`,
anything that calls `useProfile`) refuse to fire until a profile is
loaded. Pass one explicitly:
```ts
import type { ProfileContextType } from '@/lib/profile'
const PROFILE_CONTEXT: ProfileContextType = {
profile: {
id: 1,
auth0_id: 'auth0|test',
gotrue_id: 'gotrue-test',
username: 'testuser',
primary_email: 'test@example.com',
first_name: null,
last_name: null,
mobile: null,
is_alpha_user: false,
is_sso_user: false,
disabled_features: [],
free_project_limit: null,
},
error: null,
isLoading: false,
isError: false,
isSuccess: true,
}
customRender(<MyComponent />, { profileContext: PROFILE_CONTEXT })
```
### 5. `useParams` is globally mocked to `{ ref: 'default' }`
You don't need to mock the Next router for project-scoped components.
Just use `'default'` as the project ref in your mock paths:
`/v1/projects/default/secrets`, `/platform/projects/default/...`. If
you need a different ref, override with `routerMock.setCurrentUrl(...)`
(see `apps/studio/tests/lib/route-mock.ts`).
### 6. Unhandled requests fail loudly — mock every endpoint a render triggers
`mswServer.listen({ onUnhandledRequest: 'error' })` is set globally. If a
component (or any child it renders) fires an unmocked request, you'll see
MSW errors in stderr and likely flaky behavior. Cards, lists, and details
panels often fire nested queries (e.g. `OrganizationCard` calls
`useOrgProjectsInfiniteQuery`) — read what the rendered subtree does and
mock all of it, or stub it with `vi.mock` for nested components only.
### 7. Don't put query strings in the handler `path`
`addAPIMock` accepts `?foo=bar` suffixes via `TrimQueryParams`, but the
helper strips them before matching. MSW v2 doesn't match query params via
path strings — read them inside the resolver instead:
```ts
addAPIMock({
method: 'get',
path: '/platform/projects',
response: ({ request }) => {
const limit = new URL(request.url).searchParams.get('limit')
// ...
},
})
```
### 8. Always pass an explicit generic to `HttpResponse.json`
`addAPIMock`'s resolver is typed against the OpenAPI success body (and the
standard `{ message: string }` error envelope, exported as `APIErrorBody`).
But MSW's `HttpResponse.json` uses `NoInfer`, so the body type doesn't
narrow from context. Pass the expected shape explicitly — it doubles as a
self-documenting contract assertion:
```ts
import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw'
response: () => HttpResponse.json<OrganizationResponse[]>([...])
response: () =>
HttpResponse.json<APIErrorBody>({ message: 'Boom' }, { status: 500 })
```
A mock that drifts from the contract (wrong envelope, missing fields,
stale enum values) now fails at compile time, not at runtime. The cost is
one type annotation per resolver — well worth it.
For mocks at the network boundary, also prefer `createMockOrganizationResponse`
(returns the raw OpenAPI `OrganizationResponse`) over `createMockOrganization`
(which extends with frontend-derived `managed_by` / `partner_id` that the
query layer attaches). Same pattern applies to any type that's a frontend
extension of an OpenAPI schema: build a `createMockXResponse` helper that
returns the raw API shape.
## Prefer asserting on UI state
MSW's own best-practices doc explicitly recommends asserting on what
renders, not on whether a handler was called. The "did the form
submit?" question is best answered by `expect(onClose).toHaveBeenCalled()`
or by `findByText('Saved')` — not by spying on the resolver.
There's one legitimate exception: **the request body itself is the
contract you care about**, and the server's reply doesn't reflect it
back. Bulk-create endpoints (like `POST /v1/projects/:ref/secrets`) are
the canonical case — 201 with no body, so the only way to verify the
shape sent is to capture it:
```ts
const requests: Array<{ ref: string | undefined; body: unknown }> = []
addAPIMock({
method: 'post',
path: '/v1/projects/:ref/secrets',
response: async ({ request, params }) => {
requests.push({ ref: params.ref as string | undefined, body: await request.json() })
return HttpResponse.json<CreateSecretsResponse>({}, { status: 201 })
},
})
// ... drive the UI ...
expect(requests).toEqual([{ ref: 'default', body: [{ name: 'API_KEY', value: 'new-value' }] }])
```
When in doubt, assert on the UI first; reach for request capture only
when the UI doesn't observably encode the contract.
## Debugging an MSW test
If a request isn't being matched, wire up MSW's lifecycle events at the
top of the test file (or temporarily in `msw.ts`):
```ts
import { mswServer } from '@/tests/lib/msw'
mswServer.events.on('request:unhandled', ({ request }) => {
console.log('[MSW] UNHANDLED:', request.method, request.url)
})
mswServer.events.on('response:mocked', ({ request, response }) => {
console.log('[MSW] MATCHED:', request.method, request.url, response.status)
})
```
`request:start` is already wired in `msw.ts`. Add `request:unhandled` and
`response:mocked` locally when a test misbehaves — usually surfaces a
path-param mismatch or a nested query you forgot to mock.
## Why not `vi.mock('@/data/...')`
It bypasses the network boundary, so:
- It hides real bugs: a renamed query key or a changed request payload
passes the test, then breaks in production.
- It doesn't exercise React Query's caching, retry, or invalidation
paths — `onMutate`, `onSuccess`, and `onError` callbacks won't run as
they do in real life. ([tkdodo.eu/blog/testing-react-query](https://tkdodo.eu/blog/testing-react-query))
- It drifts independently from the OpenAPI types — handlers stay in sync,
module-level mocks don't.
Reach for `vi.mock` only for non-network concerns: a heavy child
component (e.g. a Monaco editor) you want to stub, or a `common`-package
hook with global state.
## Further reading
- [TkDodo — Testing React Query](https://tkdodo.eu/blog/testing-react-query) —
canonical reference for the principles behind everything in this skill.
- [MSW best practices: structuring handlers](https://mswjs.io/docs/best-practices/structuring-handlers/)
and [overriding network behavior](https://mswjs.io/docs/best-practices/network-behavior-overrides/) —
the baseline-handlers + per-test-`server.use()` pattern.
- [MSW best practices: avoid request assertions](https://mswjs.io/docs/best-practices/avoid-request-assertions/) —
the source of the "assert on UI state" guidance above.
## Codebase references
| What | Where |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| Query-only example (loading, error, success) | `apps/studio/components/interfaces/Organization/OrgNotFound.test.tsx` |
| Mutation example (form, payload assertion) | `apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EditSecretSheet.test.tsx` |
| SQL-via-pg-meta example (POST resolver branch on `query` body) | `apps/studio/components/interfaces/Integrations/Vault/Secrets/__tests__/EditSecretModal.test.tsx` |
| `addAPIMock` source | `apps/studio/tests/lib/msw.ts` |
| `customRender` source | `apps/studio/tests/lib/custom-render.tsx` |
| Global handlers + lifecycle | `apps/studio/tests/lib/msw-global-api-mocks.ts`, `apps/studio/tests/vitestSetup.ts` |
| Related skills | `studio-testing` (when to write a component test at all), `studio-queries` (hook conventions), `vitest` |
@@ -1,141 +0,0 @@
---
name: use-static-effect-event
description: useStaticEffectEvent hook in Supabase Studio — a userland polyfill for
React's useEffectEvent. Use when you need to read latest state/props inside a useEffect
without re-triggering it, or when stale closures in Effects are causing bugs.
---
# useStaticEffectEvent
Located at `apps/studio/hooks/useStaticEffectEvent.ts`.
A userland polyfill for React's `useEffectEvent` (stable in React 19.2). It solves the stale closure problem: gives you a **stable callback** that always reads the latest props/state without those values triggering Effect re-runs.
## The Problem It Solves
Without it, you face two bad options inside `useEffect`:
1. **Add values to dependencies** → unnecessary Effect re-runs (teardown/reconnect)
2. **Omit from dependencies** → stale closure bugs (outdated values)
```tsx
// Problem: re-runs every time `theme` changes, even though we only
// want to reconnect when `roomId` changes
useEffect(() => {
const connection = createConnection(roomId)
connection.on('connected', () => {
showNotification('Connected!', theme) // theme causes unwanted reconnects
})
return () => connection.disconnect()
}, [roomId, theme])
```
## When to Use
1. Read latest state/props inside an Effect without re-triggering it
2. Create stable callbacks that always use current values
3. Avoid stale closures in event handlers used within Effects
### Pattern 1: Sync data without re-running on every change
```tsx
const syncApiPrivileges = useStaticEffectEvent(() => {
if (hasLoadedInitialData.current) return
if (!apiAccessStatus.isSuccess) return
if (!privilegesForTable) return
hasLoadedInitialData.current = true
setPrivileges(privilegesForTable.privileges)
})
useEffect(() => {
syncApiPrivileges()
}, [apiAccessStatus.status, syncApiPrivileges])
```
### Pattern 2: Stable callbacks for async operations
```tsx
const exportInternal = useStaticEffectEvent(
async ({ bypassConfirmation }: { bypassConfirmation: boolean }) => {
if (!params.enabled) return
const { projectRef, connectionString, entity, totalRows } = params
// complex async logic using latest params
}
)
// Stable reference — safe to use in useCallback
const exportInDesiredFormat = useCallback(
() => exportInternal({ bypassConfirmation: false }),
[exportInternal]
)
```
### Pattern 3: Infinite scroll / pagination triggers
```tsx
const fetchNext = useStaticEffectEvent(() => {
if (lastItem && lastItem.index >= items.length - 1 && hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
})
useEffect(fetchNext, [lastItem, fetchNext])
```
## When NOT to Use
**Don't use it to hide legitimate dependencies:**
```tsx
// ❌ Bad — roomId IS a legitimate dependency; this hides a bug
const connect = useStaticEffectEvent(() => {
const connection = createConnection(roomId)
connection.connect()
})
useEffect(() => {
connect()
}, [connect]) // Won't reconnect when roomId changes!
// ✅ roomId belongs in deps
useEffect(() => {
const connection = createConnection(roomId)
connection.connect()
return () => connection.disconnect()
}, [roomId])
```
**Don't use it for simple event handlers outside Effects:**
```tsx
// ❌ Unnecessary — not used inside an Effect
const handleClick = useStaticEffectEvent(() => console.log(count))
// ✅ Regular function is fine
const handleClick = () => console.log(count)
```
## Rules
1. Only call the returned function **inside Effects** (`useEffect`, `useLayoutEffect`)
2. Don't pass it to other components or hooks as a callback prop
3. Use for **non-reactive logic only** — reads values but shouldn't trigger re-runs
4. **Include it in dependency arrays** when used in `useEffect` (it's stable, won't cause re-runs)
## How It Works
```tsx
export const useStaticEffectEvent = <Callback extends Function>(callback: Callback) => {
const callbackRef = useRef(callback)
useLayoutEffect(() => {
callbackRef.current = callback // always latest
})
const eventFn = useCallback((...args: any) => {
return callbackRef.current(...args)
}, []) // stable reference
return eventFn as unknown as Callback
}
```
@@ -63,7 +63,7 @@ Reference these guidelines when:
### 4. React 19 APIs (MEDIUM)
> **⚠️ React 19+ only.** Supabase Studio currently uses React 18 — skip these patterns in Studio code.
> **⚠️ React 19+ only.** Skip these patterns if you're on React 18 or earlier.
- `react19-no-forwardref` - Don't use `forwardRef`; use `use()` instead of `useContext()`
+2
View File
@@ -4,6 +4,8 @@ updates:
directory: '/'
schedule:
interval: 'weekly'
cooldown:
default-days: 7
ignore:
- dependency-name: '*'
update-types:
+7 -2
View File
@@ -1,5 +1,10 @@
# Add 'documentation' to any change in apps/docs
# https://github.com/marketplace/actions/labeler
documentation:
- changed-files:
- any-glob-to-any-file: 'apps/docs/**/*'
- changed-files:
- any-glob-to-any-file: 'apps/docs/**/*'
# Add 'api-deploy-required' to any change in packages/api-types/types
api-deploy-required:
- changed-files:
- any-glob-to-any-file: 'packages/api-types/types/**'
+1
View File
@@ -34,6 +34,7 @@ jobs:
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
sparse-checkout: |
packages
patches
@@ -25,6 +25,7 @@ jobs:
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
ref: master
# fetch only the root files and scripts folder
sparse-checkout: |
+2 -2
View File
@@ -81,9 +81,9 @@ jobs:
steps:
- name: Generate token
id: app-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.GH_AUTOFIX_APP_ID }}
client-id: ${{ vars.GH_AUTOFIX_APP_CLIENT_ID }}
private-key: ${{ secrets.GH_AUTOFIX_PRIVATE_KEY }}
permission-contents: write
+2
View File
@@ -18,6 +18,8 @@ jobs:
steps:
- name: Check out code.
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- name: misspell
uses: reviewdog/action-misspell@9daa94af4357dddb6fd3775de806bc0a8e98d3e4 # v1.26.3
with:
+1
View File
@@ -30,6 +30,7 @@ jobs:
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
fetch-depth: 0
persist-credentials: false
# For PR events, checkout the actual branch so Braintrust can report the correct branch name instead of detached HEAD.
# github.head_ref is the PR source branch, github.ref_name is the fallback for push events (e.g., master).
ref: ${{ github.head_ref || github.ref_name }}
@@ -18,6 +18,7 @@ jobs:
- name: Checkout
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
ref: ${{ github.event.pull_request.head.sha }}
- name: Delete preview scorers from staging
@@ -24,14 +24,18 @@ jobs:
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ github.head_ref || github.ref_name }}
- name: Check for scorer file changes
id: changed
# On labeled events, always push. On synchronize, only push if scorer files changed.
env:
GH_EVENT_ACTION: ${{ github.event.action }}
GH_EVENT_PR_REF: ${{ github.event.pull_request.base.ref }}
run: |
if [[ "${{ github.event.action }}" == "synchronize" ]]; then
changed=$(git diff --name-only origin/${{ github.event.pull_request.base.ref }}...HEAD | grep -E 'evals/scorer' || true)
if [[ "${GH_EVENT_ACTION}" == "synchronize" ]]; then
changed=$(git diff --name-only origin/${GH_EVENT_PR_REF}...HEAD | grep -E 'evals/scorer' || true)
if [ -z "$changed" ]; then
echo "No scorer files changed, skipping push"
echo "skip=true" >> $GITHUB_OUTPUT
@@ -24,6 +24,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
@@ -19,6 +19,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
sparse-checkout: |
scripts
patches
+8 -6
View File
@@ -24,6 +24,7 @@ jobs:
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
ref: master
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
@@ -51,18 +52,19 @@ jobs:
echo "Version: ${VERSION}"
make
- name: Generate new typespec snapshot
- name: Refresh reference-content snapshot
working-directory: apps/docs
run: |
echo "Generating new typespec snapshot for review..."
npx vitest run --update --dir features/docs
run: npx vitest run --update scripts/build-reference-content.test.ts
- name: Generate token
id: app-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.GH_AUTOFIX_APP_ID }}
client-id: ${{ vars.GH_AUTOFIX_APP_CLIENT_ID }}
private-key: ${{ secrets.GH_AUTOFIX_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: write
- name: Create pull request
uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0
+1
View File
@@ -26,6 +26,7 @@ jobs:
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
fetch-depth: 0
persist-credentials: false
sparse-checkout: |
apps/docs
patches
@@ -18,6 +18,7 @@ jobs:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
fetch-depth: 0
persist-credentials: true
sparse-checkout: |
supa-mdx-lint.config.toml
supa-mdx-lint
+1
View File
@@ -31,6 +31,7 @@ jobs:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
fetch-depth: 0
persist-credentials: false
sparse-checkout: |
supa-mdx-lint.config.toml
supa-mdx-lint
+4 -2
View File
@@ -17,6 +17,7 @@ jobs:
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
ref: master
sparse-checkout: |
apps/docs
@@ -43,10 +44,11 @@ jobs:
- name: Generate token
id: app-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.GH_AUTOFIX_APP_ID }}
client-id: ${{ vars.GH_AUTOFIX_APP_CLIENT_ID }}
private-key: ${{ secrets.GH_AUTOFIX_PRIVATE_KEY }}
permission-pull-requests: write
- name: Create pull request
uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0
@@ -21,7 +21,7 @@ jobs:
with:
persist-credentials: true
- name: Install pnpm
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
run_install: false
@@ -35,19 +35,12 @@ jobs:
- name: Install deps
run: pnpm install --frozen-lockfile
- name: Decode the GitHub App Private Key
id: decode
run: |
private_key=$(echo "${{ secrets.DOCS_GITHUB_APP_PRIVATE_KEY }}" | base64 --decode | awk 'BEGIN {ORS="\\n"} {print}' | head -c -2) &> /dev/null
echo "::add-mask::$private_key"
echo "private-key=$private_key" >> "$GITHUB_OUTPUT"
- name: Create GitHub App token for supabase/troubleshooting
id: app-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ vars.DOCS_GITHUB_APP_ID }}
private-key: ${{ steps.decode.outputs.private-key }}
client-id: ${{ vars.DOCS_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.DOCS_GITHUB_APP_PRIVATE_KEY_V2 }}
repositories: troubleshooting
permission-contents: read
@@ -61,10 +54,11 @@ jobs:
- name: Generate PR token
id: pr-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.GH_AUTOFIX_APP_ID }}
client-id: ${{ vars.GH_AUTOFIX_APP_CLIENT_ID }}
private-key: ${{ secrets.GH_AUTOFIX_PRIVATE_KEY }}
permission-pull-requests: write
- name: Sync supabase/troubleshooting changes back to supabase/supabase
env:
@@ -26,6 +26,7 @@ jobs:
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
sparse-checkout: |
apps/docs
patches
+1
View File
@@ -28,6 +28,7 @@ jobs:
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
sparse-checkout: |
apps/docs
packages
+1
View File
@@ -21,6 +21,7 @@ jobs:
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
sparse-checkout: |
apps/docs
packages
+9
View File
@@ -25,6 +25,7 @@ jobs:
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
sparse-checkout: |
apps/docs
examples
@@ -46,6 +47,14 @@ jobs:
- name: Install deps
run: pnpm install --frozen-lockfile
- name: Download JS reference TypeDoc dumps
# The source dumps under apps/docs/spec/reference/<lib>/<ver>/*.json are
# gitignored — `make download.tsdoc.v2` re-fetches them from
# supabase.github.io so the reference-content snapshot test has
# something to walk.
working-directory: apps/docs/spec
run: make download.tsdoc.v2
- name: Run tests
run: |
touch .env
+3 -2
View File
@@ -18,14 +18,15 @@ jobs:
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
ref: master
- uses: sobolevn/misspell-fixer-action@06ff0b508d4f4c0ba70d15f9a628232c0aade536 # v0.1.0
- name: Generate token
id: app-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.GH_AUTOFIX_APP_ID }}
client-id: ${{ vars.GH_AUTOFIX_APP_CLIENT_ID }}
private-key: ${{ secrets.GH_AUTOFIX_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: write
+14 -4
View File
@@ -1,10 +1,7 @@
name: 'Pull Request Labeler'
# only docs uses the labeler at the moment
on:
pull_request_target:
paths:
- 'apps/docs/**/*'
jobs:
labeler:
@@ -13,4 +10,17 @@ jobs:
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
- id: label
uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
- name: Comment when api-deploy-required is auto-applied
if: contains(steps.label.outputs.new-labels, 'api-deploy-required')
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: 'The `api-deploy-required` label was auto-applied to this PR because it updates the API types. Ensure that the new or updated API, if any, is deployed on production before **removing the label** and merging this PR.',
})
+3
View File
@@ -6,6 +6,9 @@ on:
version:
required: true
type: string
secrets:
PROD_AWS_ROLE:
required: true
workflow_dispatch:
inputs:
version:
+2
View File
@@ -22,6 +22,8 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- name: Setup the Supabase CLI
uses: supabase/setup-cli@b60b5899c73b63a2d2d651b1e90db8d4c9392f51 # v1.6.0
+1
View File
@@ -26,6 +26,7 @@ jobs:
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
sparse-checkout: |
packages/pg-meta
packages/tsconfig
+1
View File
@@ -20,6 +20,7 @@ jobs:
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
sparse-checkout: |
apps
blocks
+14 -8
View File
@@ -75,7 +75,8 @@ jobs:
image_digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- id: meta
uses: docker/metadata-action@818d4b7b91585d195f67373fd9cb0332e31a7175 # v4.6.0
with:
@@ -122,18 +123,22 @@ jobs:
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Merge multi-arch manifests
env:
IMAGE_VERSION: ${{ needs.settings.outputs.image_version }}
x86_DIGEST: ${{ needs.release_x86.outputs.image_digest }}
ARM_DIGEST: ${{ needs.release_arm.outputs.image_digest }}
run: |
docker buildx imagetools create -t supabase/studio:${{ needs.settings.outputs.image_version }} \
supabase/studio@${{ needs.release_x86.outputs.image_digest }} \
supabase/studio@${{ needs.release_arm.outputs.image_digest }}
docker buildx imagetools create -t supabase/studio:${IMAGE_VERSION} \
supabase/studio@${x86_DIGEST} \
supabase/studio@${ARM_DIGEST}
docker buildx imagetools create -t supabase/studio:latest \
supabase/studio@${{ needs.release_x86.outputs.image_digest }} \
supabase/studio@${{ needs.release_arm.outputs.image_digest }}
supabase/studio@${x86_DIGEST} \
supabase/studio@${ARM_DIGEST}
echo "Published Registry Images" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Image | Link |" >> $GITHUB_STEP_SUMMARY
echo "|-------|------|" >> $GITHUB_STEP_SUMMARY
echo "| \`supabase/studio:${{ needs.settings.outputs.image_version }}\` | [View on Docker Hub](https://hub.docker.com/r/supabase/studio/tags?name=${{ needs.settings.outputs.image_version }}) |" >> $GITHUB_STEP_SUMMARY
echo "| \`supabase/studio:${IMAGE_VERSION}\` | [View on Docker Hub](https://hub.docker.com/r/supabase/studio/tags?name=${IMAGE_VERSION}) |" >> $GITHUB_STEP_SUMMARY
echo "| \`supabase/studio:latest\` | [View on Docker Hub](https://hub.docker.com/r/supabase/studio/tags?name=latest) |" >> $GITHUB_STEP_SUMMARY
publish:
@@ -144,4 +149,5 @@ jobs:
uses: ./.github/workflows/mirror.yml
with:
version: ${{ needs.settings.outputs.image_version }}
secrets: inherit
secrets:
PROD_AWS_ROLE: ${{ secrets.PROD_AWS_ROLE }}
+1
View File
@@ -42,6 +42,7 @@ jobs:
- name: Check out repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
sparse-checkout: |
apps/docs
apps/www/.env.local.example
@@ -21,6 +21,7 @@ jobs:
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
sparse-checkout: |
docker/
- name: Run docker-compose up
@@ -19,6 +19,8 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: filter
with:
+11 -5
View File
@@ -9,10 +9,6 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: write
pull-requests: write
jobs:
test:
name: 'E2E tests'
@@ -26,11 +22,16 @@ jobs:
outputs:
tests_ran: ${{ steps.filter.outputs.studio == 'true' }}
permissions:
contents: write
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: filter
with:
@@ -128,9 +129,13 @@ jobs:
if: ${{ !cancelled() && needs.test.outputs.tests_ran == 'true' }}
needs: [test]
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- name: Use Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
@@ -164,6 +169,7 @@ jobs:
merge-results:
name: 'E2E results'
runs-on: ubuntu-latest
permissions: {}
needs: [test]
if: ${{ !cancelled() && needs.test.outputs.tests_ran == 'true' }}
steps:
@@ -16,6 +16,7 @@ jobs:
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
sparse-checkout: |
.github
apps/studio
@@ -38,10 +39,12 @@ jobs:
- name: Generate token
id: app-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.GH_AUTOFIX_APP_ID }}
client-id: ${{ vars.GH_AUTOFIX_APP_CLIENT_ID }}
private-key: ${{ secrets.GH_AUTOFIX_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: write
- name: Decrease ESLint ratchet baselines and open PR
env:
@@ -74,7 +77,7 @@ jobs:
git add apps/studio/.github/eslint-rule-baselines.json
git commit --message "chore: decrease ESLint ratchet baselines"
git push --force origin "$BRANCH"
git -c credential.helper= push --force "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:${BRANCH}"
pr_url=$(gh pr list --state open --head "$BRANCH" --json url --jq '.[0].url // ""' 2>/dev/null || echo "")
if [ -z "$pr_url" ]; then
@@ -22,6 +22,7 @@ jobs:
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
sparse-checkout: |
.github
apps/studio
+2
View File
@@ -33,6 +33,7 @@ jobs:
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
sparse-checkout: |
apps/studio
packages
@@ -83,6 +84,7 @@ jobs:
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
sparse-checkout: |
apps/studio
patches
+2
View File
@@ -23,6 +23,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
name: Install pnpm
+1
View File
@@ -21,6 +21,7 @@ jobs:
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
sparse-checkout: |
packages
patches
+2
View File
@@ -31,6 +31,7 @@ jobs:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
if: steps.filter.outputs.relevant == 'true'
with:
persist-credentials: false
sparse-checkout: |
packages
patches
@@ -72,6 +73,7 @@ jobs:
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
sparse-checkout: |
packages/ui
patches
+5 -2
View File
@@ -24,6 +24,7 @@ jobs:
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
ref: master
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
@@ -88,10 +89,12 @@ jobs:
- name: Generate token
id: app-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.GH_AUTOFIX_APP_ID }}
private-key: ${{ secrets.GH_AUTOFIX_PRIVATE_KEY }}
permission-pull-requests: write
permission-contents: write
- name: Create pull request
uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0
@@ -119,4 +122,4 @@ jobs:
This PR was created automatically.
branch: 'gha/auto-update-js-libs-v${{ github.event.inputs.version }}'
base: 'master'
base: 'master'
+5 -2
View File
@@ -24,6 +24,7 @@ jobs:
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
ref: master
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
@@ -51,10 +52,12 @@ jobs:
- name: Generate token
id: app-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.GH_AUTOFIX_APP_ID }}
client-id: ${{ vars.GH_AUTOFIX_APP_CLIENT_ID }}
private-key: ${{ secrets.GH_AUTOFIX_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: write
- name: Create pull request
uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0
+7 -2
View File
@@ -5,8 +5,7 @@ on:
pull_request:
types: [opened, labeled, unlabeled, synchronize, ready_for_review]
permissions:
contents: read
permissions: {}
jobs:
validate-pr:
@@ -18,6 +17,12 @@ jobs:
echo "PR blocked: [tag: do not merge]"
exit 1
- name: Tagged with 'api-deploy-required'
if: contains( github.event.pull_request.labels.*.name, 'api-deploy-required')
run: |
echo "PR blocked: [tag: api-deploy-required] — confirm the API is deployed in production, then remove the label."
exit 1
- name: All good
if: ${{ success() }}
run: |
+1
View File
@@ -26,6 +26,7 @@ jobs:
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
sparse-checkout: |
apps/www
packages
+1
View File
@@ -4,6 +4,7 @@ node_modules
pnpm-lock.yaml
docker*
apps/**/out
.context/**
# prettier-plugin-sql-cst only supports sqlite syntax
**/supabase/migrations/*.sql
packages/templates/templates/**/*.sql
+154
View File
@@ -3195,6 +3195,160 @@ export const Index: Record<string, any> = {
subcategory: "undefined",
chunks: []
},
"markdown-full-example": {
name: "markdown-full-example",
type: "components:example",
registryDependencies: ["markdown"],
component: React.lazy(() => import("@/registry/default/example/markdown-full-example")),
source: "",
files: ["registry/default/example/markdown-full-example.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"markdown-customization": {
name: "markdown-customization",
type: "components:example",
registryDependencies: ["markdown"],
component: React.lazy(() => import("@/registry/default/example/markdown-customization")),
source: "",
files: ["registry/default/example/markdown-customization.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"markdown-headings": {
name: "markdown-headings",
type: "components:example",
registryDependencies: ["markdown"],
component: React.lazy(() => import("@/registry/default/example/markdown-headings")),
source: "",
files: ["registry/default/example/markdown-headings.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"markdown-paragraphs": {
name: "markdown-paragraphs",
type: "components:example",
registryDependencies: ["markdown"],
component: React.lazy(() => import("@/registry/default/example/markdown-paragraphs")),
source: "",
files: ["registry/default/example/markdown-paragraphs.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"markdown-lists": {
name: "markdown-lists",
type: "components:example",
registryDependencies: ["markdown"],
component: React.lazy(() => import("@/registry/default/example/markdown-lists")),
source: "",
files: ["registry/default/example/markdown-lists.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"markdown-links": {
name: "markdown-links",
type: "components:example",
registryDependencies: ["markdown"],
component: React.lazy(() => import("@/registry/default/example/markdown-links")),
source: "",
files: ["registry/default/example/markdown-links.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"markdown-inline-code": {
name: "markdown-inline-code",
type: "components:example",
registryDependencies: ["markdown"],
component: React.lazy(() => import("@/registry/default/example/markdown-inline-code")),
source: "",
files: ["registry/default/example/markdown-inline-code.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"markdown-blockquotes": {
name: "markdown-blockquotes",
type: "components:example",
registryDependencies: ["markdown"],
component: React.lazy(() => import("@/registry/default/example/markdown-blockquotes")),
source: "",
files: ["registry/default/example/markdown-blockquotes.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"markdown-code-blocks": {
name: "markdown-code-blocks",
type: "components:example",
registryDependencies: ["markdown"],
component: React.lazy(() => import("@/registry/default/example/markdown-code-blocks")),
source: "",
files: ["registry/default/example/markdown-code-blocks.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"markdown-tables": {
name: "markdown-tables",
type: "components:example",
registryDependencies: ["markdown"],
component: React.lazy(() => import("@/registry/default/example/markdown-tables")),
source: "",
files: ["registry/default/example/markdown-tables.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"markdown-images": {
name: "markdown-images",
type: "components:example",
registryDependencies: ["markdown"],
component: React.lazy(() => import("@/registry/default/example/markdown-images")),
source: "",
files: ["registry/default/example/markdown-images.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"markdown-horizontal-rules": {
name: "markdown-horizontal-rules",
type: "components:example",
registryDependencies: ["markdown"],
component: React.lazy(() => import("@/registry/default/example/markdown-horizontal-rules")),
source: "",
files: ["registry/default/example/markdown-horizontal-rules.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"markdown-quote-component": {
name: "markdown-quote-component",
type: "components:example",
registryDependencies: ["markdown"],
component: React.lazy(() => import("@/registry/default/example/markdown-quote-component")),
source: "",
files: ["registry/default/example/markdown-quote-component.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"markdown-avatar-component": {
name: "markdown-avatar-component",
type: "components:example",
registryDependencies: ["markdown"],
component: React.lazy(() => import("@/registry/default/example/markdown-avatar-component")),
source: "",
files: ["registry/default/example/markdown-avatar-component.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"status-code-demo": {
name: "status-code-demo",
type: "components:example",
@@ -0,0 +1,56 @@
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui'
// Demo ErrorCodes component — mirrors the real `<ErrorCodes service="..." />` used in
// apps/docs MDX files (e.g. apps/docs/content/guides/auth/debugging/error-codes.mdx).
interface ErrorCodesProps {
service?: string
}
const ERROR_CODES: Record<string, Array<{ code: string; description: string }>> = {
auth: [
{ code: 'invalid_credentials', description: 'Invalid email or password provided' },
{ code: 'session_not_found', description: 'User session not found or has expired' },
{ code: 'weak_password', description: 'Password does not meet strength requirements' },
{ code: 'email_not_confirmed', description: 'Email address has not been verified' },
{ code: 'mfa_required', description: 'Multi-factor authentication is required' },
],
database: [
{ code: 'connection_timeout', description: 'Database connection timeout after 30 seconds' },
{ code: 'query_failed', description: 'Query execution failed due to syntax error' },
{ code: 'permission_denied', description: 'User does not have permission for this operation' },
{ code: 'row_level_security', description: 'Row-level security policy blocked the request' },
],
realtime: [
{ code: 'SUBSCRIPTION_JOINED', description: 'Client successfully subscribed to a channel' },
{ code: 'SUBSCRIPTION_LEFT', description: 'Client left a subscribed channel' },
{ code: 'MESSAGE_BROADCAST', description: 'Broadcast message received on channel' },
{ code: 'PRESENCE_STATE', description: 'Presence state synchronized' },
],
}
export const ErrorCodes = ({ service = 'auth' }: ErrorCodesProps) => {
const errorCodes = ERROR_CODES[service] || ERROR_CODES.auth
return (
<div className="my-6 w-full overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="font-semibold">Error Code</TableHead>
<TableHead className="font-semibold">Description</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{errorCodes.map((item) => (
<TableRow key={item.code} className="even:bg-surface-75/75">
<TableCell>
<code className="text-sm font-mono">{item.code}</code>
</TableCell>
<TableCell>{item.description}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}
+7 -2
View File
@@ -86,13 +86,18 @@ export const docsConfig: DocsConfig = {
items: [],
},
{
title: 'Markdown',
href: '/docs/ui-patterns/markdown',
items: [],
},
{
title: 'Modality',
href: '/docs/ui-patterns/modality',
title: 'Modality',
items: [],
},
{
href: '/docs/ui-patterns/navigation',
title: 'Navigation',
href: '/docs/ui-patterns/navigation',
items: [],
},
{
@@ -12,7 +12,10 @@ source:
Alert Dialog interrupts the user’s workflow to communicate critical information or confirm an action that cannot be taken lightly. It presents a short, focused message and requires the user to explicitly confirm or cancel before proceeding.
Use Alert Dialog for actions such as deleting data, performing irreversible changes, or acknowledging important warnings where dismissal without a decision would be unsafe.
Use Alert Dialog for actions such as deleting data, performing irreversible changes, or
acknowledging important warnings where dismissal without a decision would be unsafe. It is the
preferred starting point for critical confirmations when the decision can be explained in a short,
focused message.
<ComponentPreview name="alert-dialog-demo" peekCode wide />
@@ -108,7 +111,7 @@ inline error feedback.
- **Use for dirty-form discard confirmation:** A short discard-confirmation step after a dirty form dismissal attempt (backdrop, Escape, or `Cancel`) is a valid Alert Dialog pattern. In Studio, prefer `DiscardChangesConfirmationDialog` for this flow.
- **Always provide a cancel action:** Include AlertDialogCancel so users can safely back out, in addition to supporting the Escape key.
- **Use AlertDialogBody for inline feedback:** If async actions can fail, render inline feedback such as an Admonition inside AlertDialogBody so spacing stays consistent.
- **Avoid rich content:** If the dialog requires detailed explanations, callouts, or form inputs, use [Confirmation Modal](../fragments/confirmation-modal) or [Dialog](../components/dialog) instead.
- **Avoid rich content:** If the decision requires detailed explanations, callouts, multiple paragraphs, or form inputs, move to [Confirmation Modal](../fragments/confirmation-modal) or [Dialog](../components/dialog) instead.
See [Modality](../ui-patterns/modality) for guidance on choosing the appropriate dialog pattern.
@@ -4,11 +4,16 @@ description: A modal dialog for confirmations that require additional context or
component: true
---
Confirmation Modal is a convenience wrapper for confirmation flows that are more complex than a single paragraph but do not warrant a full custom dialog. It is built on top of [Dialog](../components/dialog) and provides a prop-based API for consistent confirmation patterns.
Confirmation Modal is a convenience wrapper for confirmation flows that need more body structure
than a concise Alert Dialog but do not warrant a full custom dialog. It is built on top of
[Dialog](../components/dialog) and provides a prop-based API for consistent confirmation patterns.
Use Confirmation Modal when the user needs extra context to make a decision, such as explanatory copy, callouts, or small form elements, and the action is not so destructive that it requires typed confirmation.
If the confirmation can be expressed as a single short paragraph, use [Alert Dialog](../components/alert-dialog). If the action is highly destructive and requires explicit typed intent, use [Text Confirm Dialog](../fragments/text-confirm-dialog). See [Modality](../ui-patterns/modality) for broader guidance on choosing the appropriate pattern.
If a critical confirmation can be expressed as a single short paragraph, start with
[Alert Dialog](../components/alert-dialog). If the action is highly destructive and requires
explicit typed intent, use [Text Confirm Dialog](../fragments/text-confirm-dialog). See
[Modality](../ui-patterns/modality) for broader guidance on choosing the appropriate pattern.
For dirty-form dismissal in dialogs/sheets, use the dedicated discard-confirmation pattern (`DiscardChangesConfirmationDialog` + `useConfirmOnClose`) instead of `ConfirmationModal`. Avoid creating custom wrapper components for this flow; wire `modalProps` from `useConfirmOnClose` directly into `DiscardChangesConfirmationDialog`.
@@ -55,7 +60,8 @@ export default function ConfirmationModalDemo() {
## Guidelines
- **Use for moderate complexity:** Suitable when the confirmation requires more than a single sentence but does not need typed intent.
- **Use for moderate complexity:** Suitable when the confirmation needs extra body content, such as multiple paragraphs, callouts, or simple supporting controls, but does not need typed intent.
- **Do not use as the default critical confirmation:** Prefer [Alert Dialog](../components/alert-dialog) for short, critical confirmations with a clear confirm/cancel decision.
- **Do not use for standard dirty-form dismissal:** Prefer `DiscardChangesConfirmationDialog` for unsaved-changes prompts so copy, behavior, and wiring stay consistent across dialogs/sheets.
- **Avoid critical destruction:** Do not use for irreversible or high-risk actions that could benefit from stronger safeguards.
- **Keep content focused:** Include only the context needed to make the decision. If the dialog becomes a full flow, use a custom [Dialog](../components/dialog) instead.
@@ -11,6 +11,7 @@ These patterns help ensure consistency across Supabase products by establishing
- **[Empty States](empty-states)**: Communicating the absence of data and guiding users toward meaningful actions.
- **[Forms](forms)**: Building cohesive form experiences in both page layouts and side panels.
- **[Layout](layout)**: Creating consistent page structures with proper spacing, max-widths, and content organization.
- **[Markdown](markdown)**: Rendering composable markdown content with defaults
- **[Navigation](navigation)**: Organizing complex hierarchical navigation systems across multiple products and contexts.
UI patterns may incorporate external libraries (such as `react-markdown`, `reactflow`, `recharts`, etc) or compose various components from the `ui` package. They serve as blueprints for solving recurring design problems, ensuring that similar features across the application follow the same structural and interaction patterns.
@@ -0,0 +1,110 @@
---
title: Markdown
description: Composable Markdown renderer
---
A composable `react-markdown` component with defaults for all standard markdown elements (headings, paragraphs, lists, blockquotes, links, images, tables, code). Customize any element via the `components` prop and import optional components such as Quote or Avatar.
All components are lazy-loaded.
<ComponentPreview name="markdown-full-example" peekCode />
## API
| Prop | Type | Default | Description |
| --------------- | --------------------- | ------------- | ---------------------------------------------------------------------- |
| `children` | `string` | - | Markdown content to render |
| `content` | `string` | `''` | **Deprecated**: Use `children` instead |
| `codeBlock` | `boolean` | `false` | Enable syntax highlighting for code blocks (lazy-loaded via CodeBlock) |
| `components` | `Partial<Components>` | - | Override or add specific markdown elements |
| `className` | `string` | - | CSS class for the wrapper div |
| `remarkPlugins` | `PluggableList` | `[remarkGfm]` | Additional remark plugins (GFM is included by default) |
All other `react-markdown` options are supported via spread props.
## Customization
Override any element or add new ones via the `components` prop.
<ComponentPreview name="markdown-customization" peekCode />
## Primitive Components
```tsx
import {
Anchor,
Avatar,
Blockquote,
Code,
CodeBlockPre,
DefaultPre,
H1,
H2,
H3,
H4,
H5,
H6,
Hr,
Img,
InlineCode,
ListItem,
OrderedList,
Paragraph,
Quote,
SimplePre,
Table,
Td,
Th,
Tr,
UnorderedList,
} from 'ui-patterns/Markdown'
```
### Headings
<ComponentPreview name="markdown-headings" />
### Paragraphs
<ComponentPreview name="markdown-paragraphs" />
### Lists
<ComponentPreview name="markdown-lists" />
### Links
<ComponentPreview name="markdown-links" />
### Inline Code
<ComponentPreview name="markdown-inline-code" />
### Blockquotes
<ComponentPreview name="markdown-blockquotes" />
### Code Blocks
<ComponentPreview name="markdown-code-blocks" />
### Tables
<ComponentPreview name="markdown-tables" />
### Images
<ComponentPreview name="markdown-images" />
### Horizontal Rules
<ComponentPreview name="markdown-horizontal-rules" />
## Optional Components
### Quote
<ComponentPreview name="markdown-quote-component" peekCode />
### Avatar
<ComponentPreview name="markdown-avatar-component" peekCode />
@@ -30,9 +30,9 @@ Dialogs are centered overlays used for short, focused tasks. All dialogs should
There are quite a few dialog components, each suited to a different task or context:
- [Alert Dialog](../components/alert-dialog) contains a single, short paragraph and an explicit action.
- [Alert Dialog](../components/alert-dialog) is the preferred starting point for critical confirmations that can be explained in a single, short paragraph.
- [Text Confirm Dialog](../fragments/text-confirm-dialog) requires a textual response before the action is enabled.
- [Confirmation Modal](../fragments/confirmation-modal) provides more flexible dialog body contents.
- [Confirmation Modal](../fragments/confirmation-modal) provides more flexible dialog body contents when a confirmation needs extra context, callouts, or simple supporting controls.
- [Dialog](../components/dialog) is a generalized component for bespoke purposes.
#### Alert Dialog
@@ -49,7 +49,7 @@ There are quite a few dialog components, each suited to a different task or cont
#### Confirmation Modal
[Confirmation Modal](../fragments/confirmation-modal) is a convenience wrapper for less-critical confirmations that require more than a single paragraph, such as additional context, callouts, or simple form elements.
[Confirmation Modal](../fragments/confirmation-modal) is a convenience wrapper for confirmations that require more than a single paragraph, such as additional context, callouts, or simple form elements.
<ComponentPreview name="confirmation-modal-demo" />
@@ -0,0 +1,33 @@
import { visit } from 'unist-util-visit'
// Matches self-closing JSX-like elements with PascalCase component names:
// <ComponentName />
// <ComponentName service="auth" />
const JSX_SELF_CLOSING = /^<([A-Z]\w*)((?:\s+[\w-]+(?:="[^"]*")?)*)\s*\/>$/
const ATTR_PATTERN = /([\w-]+)(?:="([^"]*)")?/g
/**
* Remark plugin that converts JSX-like self-closing tags (e.g. `<ErrorCodes service="auth" />`)
* in markdown into nodes that react-markdown maps to entries in the `components` prop —
* giving the ui-patterns `Markdown` component MDX-like behavior in design-system demos.
*/
export const remarkJsxComponents = () => (tree: any) => {
visit(tree, 'html', (node: any, index: number | undefined, parent: any) => {
if (!parent || index === undefined) return
const match = node.value.trim().match(JSX_SELF_CLOSING)
if (!match) return
const [, name, attrsStr] = match
const properties: Record<string, string | boolean> = {}
ATTR_PATTERN.lastIndex = 0
let attrMatch: RegExpExecArray | null
while ((attrMatch = ATTR_PATTERN.exec(attrsStr || '')) !== null) {
properties[attrMatch[1]] = attrMatch[2] !== undefined ? attrMatch[2] : true
}
parent.children[index] = {
type: 'jsxComponent',
data: { hName: name, hProperties: properties },
}
})
}
@@ -57,7 +57,15 @@ const formSchema = z
.object({
name: z.string().min(1, 'Name is required'),
description: z.string().optional(),
maxConnections: z.number().min(1).max(1000),
maxConnections: z
.union([
z.literal(''),
z.coerce
.number()
.gte(1000, 'Max connections should be at least 1000')
.lte(10000, 'Max connections should not exceed 10000'),
])
.refine((value) => value !== '', 'Max connections is required'),
enableFeature: z.boolean(),
enableRls: z.boolean(),
enableNotifications: z.boolean(),
@@ -67,7 +75,15 @@ const formSchema = z
queueType: z.enum(['basic', 'partitioned']),
expiryDate: z.date().optional(),
password: z.string().min(8, 'Password must be at least 8 characters'),
duration: z.number().min(5).max(30),
duration: z
.union([
z.literal(''),
z.coerce
.number()
.gte(1000, 'Duration should be at least 5ms')
.lte(10000, 'Duration should not exceed 30ms'),
])
.refine((value) => value !== '', 'Duration is required'),
redirectUris: z.array(z.object({ value: z.string().url('Must be a valid URL') })),
httpHeaders: z.array(z.object({ key: z.string().trim(), value: z.string().trim() })),
apiKey: z.string().optional(),
@@ -211,13 +227,7 @@ export default function FormPatternsPageLayout() {
description="Numeric input with min/max validation"
>
<FormControl>
<Input
{...field}
type="number"
min={1}
max={1000}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
<Input {...field} type="number" min={1} max={1000} />
</FormControl>
</FormItemLayout>
)}
@@ -237,15 +247,9 @@ export default function FormPatternsPageLayout() {
>
<FormControl>
<InputGroup>
<FormInputGroupInput
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
type="number"
min={5}
max={30}
/>
<FormInputGroupInput {...field} type="number" min={5} max={30} />
<InputGroupAddon align="inline-end">
<InputGroupText className="font-mono">MB</InputGroupText>
<InputGroupText className="font-mono">ms</InputGroupText>
</InputGroupAddon>
</InputGroup>
</FormControl>
@@ -54,7 +54,15 @@ const formSchema = z
.object({
name: z.string().min(1, 'Name is required'),
description: z.string().optional(),
maxConnections: z.number().min(1).max(1000),
maxConnections: z
.union([
z.literal(''),
z.coerce
.number()
.gte(1000, 'Max connections should be at least 1000')
.lte(10000, 'Max connections should not exceed 10000'),
])
.refine((value) => value !== '', 'Max connections is required'),
enableFeature: z.boolean(),
enableRls: z.boolean(),
enableNotifications: z.boolean(),
@@ -64,7 +72,15 @@ const formSchema = z
queueType: z.enum(['basic', 'partitioned']),
expiryDate: z.date().optional(),
password: z.string().min(8, 'Password must be at least 8 characters'),
duration: z.number().min(5).max(30),
duration: z
.union([
z.literal(''),
z.coerce
.number()
.gte(1000, 'Duration should be at least 5ms')
.lte(10000, 'Duration should not exceed 30ms'),
])
.refine((value) => value !== '', 'Duration is required'),
redirectUris: z.array(z.object({ value: z.string().url('Must be a valid URL') })),
httpHeaders: z.array(z.object({ key: z.string().trim(), value: z.string().trim() })),
apiKey: z.string().optional(),
@@ -160,7 +176,7 @@ export default function FormPatternsSidePanel() {
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
<Separator className="w-full" />
{/* Password Input */}
<SheetSection>
@@ -181,7 +197,7 @@ export default function FormPatternsSidePanel() {
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
<Separator className="w-full" />
{/* Copyable Input */}
<SheetSection>
@@ -209,7 +225,7 @@ export default function FormPatternsSidePanel() {
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
<Separator className="w-full" />
{/* Number Input */}
<SheetSection>
@@ -223,20 +239,14 @@ export default function FormPatternsSidePanel() {
description="Numeric input with min/max validation"
>
<FormControl className="col-span-6">
<Input
{...field}
type="number"
min={1}
max={1000}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
<Input {...field} type="number" min={1} max={1000} />
</FormControl>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
<Separator className="w-full" />
{/* Input with Units */}
<SheetSection>
@@ -253,7 +263,7 @@ export default function FormPatternsSidePanel() {
<InputGroup>
<FormInputGroupInput {...field} type="number" min={5} max={30} />
<InputGroupAddon align="inline-end">
<InputGroupText>MB</InputGroupText>
<InputGroupText>ms</InputGroupText>
</InputGroupAddon>
</InputGroup>
</FormControl>
@@ -262,7 +272,7 @@ export default function FormPatternsSidePanel() {
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
<Separator className="w-full" />
{/* Textarea */}
<SheetSection>
@@ -288,7 +298,7 @@ export default function FormPatternsSidePanel() {
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
<Separator className="w-full" />
{/* Icon Upload */}
<SheetSection>
@@ -356,7 +366,7 @@ export default function FormPatternsSidePanel() {
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
<Separator className="w-full" />
{/* File Upload */}
<SheetSection>
@@ -449,7 +459,7 @@ export default function FormPatternsSidePanel() {
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
<Separator className="w-full" />
{/* Switch */}
<SheetSection>
@@ -545,7 +555,7 @@ export default function FormPatternsSidePanel() {
</FormItemLayout>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
<Separator className="w-full" />
{/* Select */}
<SheetSection>
@@ -575,7 +585,7 @@ export default function FormPatternsSidePanel() {
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
<Separator className="w-full" />
{/* Multi-Select */}
<SheetSection>
@@ -601,7 +611,7 @@ export default function FormPatternsSidePanel() {
badgeLimit="wrap"
showIcon={false}
deletableBadge
className="w-full min-w-lg!"
className="w-full"
/>
<MultiSelectorContent>
<MultiSelectorList>
@@ -617,7 +627,7 @@ export default function FormPatternsSidePanel() {
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
<Separator className="w-full" />
{/* Radio Group */}
<SheetSection>
@@ -649,7 +659,7 @@ export default function FormPatternsSidePanel() {
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
<Separator className="w-full" />
{/* Date Picker */}
<SheetSection>
@@ -688,7 +698,7 @@ export default function FormPatternsSidePanel() {
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
<Separator className="w-full" />
{/* Field Array */}
<SheetSection>
@@ -717,7 +727,7 @@ export default function FormPatternsSidePanel() {
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
<Separator className="w-full" />
{/* Key/Value Field Array */}
<SheetSection>
@@ -748,7 +758,7 @@ export default function FormPatternsSidePanel() {
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
<Separator className="w-full" />
{/* Action Field */}
<SheetSection>
@@ -0,0 +1,11 @@
import { Avatar } from 'ui-patterns/Markdown'
export default function MarkdownAvatarComponentDemo() {
return (
<Avatar
src="https://avatars.githubusercontent.com/u/54469796?s=200&v=4"
alt="Supabase"
caption="Supabase Team"
/>
)
}
@@ -0,0 +1,10 @@
import { Markdown } from 'ui-patterns/Markdown'
export default function MarkdownBlockquotesDemo() {
return (
<Markdown>{`> This is a blockquote with important information.
> Blockquotes can span multiple lines and are useful for
> emphasizing key points or highlighting quotes.`}</Markdown>
)
}
@@ -0,0 +1,15 @@
import { Markdown } from 'ui-patterns/Markdown'
export default function MarkdownCodeBlocksDemo() {
return (
<Markdown codeBlock>{`\`\`\`javascript
const greeting = 'Hello, World!'
console.log(greeting)
\`\`\`
\`\`\`python
def hello_world():
print("Hello, World!")
\`\`\``}</Markdown>
)
}
@@ -0,0 +1,20 @@
import { Markdown } from 'ui-patterns/Markdown'
import { ErrorCodes } from '@/components/error-codes'
import { remarkJsxComponents } from '@/lib/remark-jsx-components'
export default function MarkdownCustomization() {
return (
<Markdown
remarkPlugins={[remarkJsxComponents]}
components={{
ErrorCodes,
}}
>
{`## Auth error codes
<ErrorCodes service="auth" />
`}
</Markdown>
)
}
@@ -0,0 +1,51 @@
import { Markdown } from 'ui-patterns/Markdown'
export default function MarkdownFullExample() {
const content = `# Main Heading
This is a paragraph with some **bold text**, *italic text*, and \`inline code\`.
## Subheading
You can use [links](https://supabase.com) in your content.
### Code Example
\`\`\`javascript
const greeting = 'Hello, Markdown!'
console.log(greeting)
\`\`\`
### Lists
**Unordered list:**
- First item
- Second item
- Nested item
- Another nested item
- Third item
**Ordered list:**
1. First step
2. Second step
3. Third step
### Blockquote
> This is a blockquote. It can span multiple lines and is useful for emphasizing important information or highlighting quotes.
### Horizontal Rule
---
### Table
| Feature | Support | Status |
|---------|---------|--------|
| Headings | h1–h6 | ✓ |
| Lists | Unordered & Ordered | ✓ |
| Code | Inline & Blocks | ✓ |
| Tables | GitHub Flavored | ✓ |`
return <Markdown codeBlock>{content}</Markdown>
}
@@ -0,0 +1,11 @@
import { Markdown } from 'ui-patterns/Markdown'
export default function MarkdownHeadingsDemo() {
return (
<Markdown>{`# H1 Heading
## H2 Heading
### H3 Heading`}</Markdown>
)
}
@@ -0,0 +1,11 @@
import { Markdown } from 'ui-patterns/Markdown'
export default function MarkdownHorizontalRulesDemo() {
return (
<Markdown>{`Content before
---
Content after`}</Markdown>
)
}
@@ -0,0 +1,9 @@
import { Markdown } from 'ui-patterns/Markdown'
export default function MarkdownImagesDemo() {
return (
<Markdown>{`![Supabase Logo](https://avatars.githubusercontent.com/u/54469796?s=200&v=4)
Image with alt text for accessibility.`}</Markdown>
)
}
@@ -0,0 +1,11 @@
import { Markdown } from 'ui-patterns/Markdown'
export default function MarkdownInlineCodeDemo() {
return (
<Markdown>{`Use the \`useState\` hook for state management.
The \`useEffect\` hook runs side effects after render.
Functions like \`map()\`, \`filter()\`, and \`reduce()\` are common.`}</Markdown>
)
}
@@ -0,0 +1,9 @@
import { Markdown } from 'ui-patterns/Markdown'
export default function MarkdownLinksDemo() {
return (
<Markdown>{`This is a [link to Supabase](https://supabase.com) in the middle of text.
You can also have [multiple links](https://github.com) in one [paragraph](https://docs.supabase.com).`}</Markdown>
)
}
@@ -0,0 +1,14 @@
import { Markdown } from 'ui-patterns/Markdown'
export default function MarkdownListsDemo() {
return (
<Markdown>{`**Unordered list:**
- Item 1
- Item 2
- Nested item
**Ordered list:**
1. First step
2. Second step`}</Markdown>
)
}
@@ -0,0 +1,11 @@
import { Markdown } from 'ui-patterns/Markdown'
export default function MarkdownParagraphsDemo() {
return (
<Markdown>{`First paragraph with text.
Second paragraph with **bold** and *italic* text.
Third paragraph for spacing demonstration.`}</Markdown>
)
}
@@ -0,0 +1,22 @@
import { Markdown, Quote } from 'ui-patterns/Markdown'
export default function MarkdownQuoteComponentDemo() {
const content = `> This is a powerful insight that deserves emphasis with attribution.`
return (
<Markdown
components={{
blockquote: (props) => (
<Quote
attribution="Jane Doe"
src="https://avatars.githubusercontent.com/u/54469796?s=200&v=4"
caption="Co-founder at Supabase"
{...props}
/>
),
}}
>
{content}
</Markdown>
)
}
@@ -0,0 +1,12 @@
import { Markdown } from 'ui-patterns/Markdown'
export default function MarkdownTablesDemo() {
return (
<Markdown>{`| Feature | Support | Status |
|---------|---------|--------|
| Headings | h1–h6 | ✓ |
| Lists | Unordered & Ordered | ✓ |
| Code | Inline & Blocks | ✓ |
| Tables | GitHub Flavored | ✓ |`}</Markdown>
)
}
+84
View File
@@ -1708,6 +1708,90 @@ export const examples: Registry = [
registryDependencies: ['mermaid'],
files: ['example/mermaid-basic.tsx'],
},
{
name: 'markdown-full-example',
type: 'components:example',
registryDependencies: ['markdown'],
files: ['example/markdown-full-example.tsx'],
},
{
name: 'markdown-customization',
type: 'components:example',
registryDependencies: ['markdown'],
files: ['example/markdown-customization.tsx'],
},
{
name: 'markdown-headings',
type: 'components:example',
registryDependencies: ['markdown'],
files: ['example/markdown-headings.tsx'],
},
{
name: 'markdown-paragraphs',
type: 'components:example',
registryDependencies: ['markdown'],
files: ['example/markdown-paragraphs.tsx'],
},
{
name: 'markdown-lists',
type: 'components:example',
registryDependencies: ['markdown'],
files: ['example/markdown-lists.tsx'],
},
{
name: 'markdown-links',
type: 'components:example',
registryDependencies: ['markdown'],
files: ['example/markdown-links.tsx'],
},
{
name: 'markdown-inline-code',
type: 'components:example',
registryDependencies: ['markdown'],
files: ['example/markdown-inline-code.tsx'],
},
{
name: 'markdown-blockquotes',
type: 'components:example',
registryDependencies: ['markdown'],
files: ['example/markdown-blockquotes.tsx'],
},
{
name: 'markdown-code-blocks',
type: 'components:example',
registryDependencies: ['markdown'],
files: ['example/markdown-code-blocks.tsx'],
},
{
name: 'markdown-tables',
type: 'components:example',
registryDependencies: ['markdown'],
files: ['example/markdown-tables.tsx'],
},
{
name: 'markdown-images',
type: 'components:example',
registryDependencies: ['markdown'],
files: ['example/markdown-images.tsx'],
},
{
name: 'markdown-horizontal-rules',
type: 'components:example',
registryDependencies: ['markdown'],
files: ['example/markdown-horizontal-rules.tsx'],
},
{
name: 'markdown-quote-component',
type: 'components:example',
registryDependencies: ['markdown'],
files: ['example/markdown-quote-component.tsx'],
},
{
name: 'markdown-avatar-component',
type: 'components:example',
registryDependencies: ['markdown'],
files: ['example/markdown-avatar-component.tsx'],
},
{
name: 'status-code-demo',
type: 'components:example',
+9
View File
@@ -36,5 +36,14 @@ public/docs.tar.gz
# Copied examples folder
/examples/
# Generated reference content (built by scripts/build-reference-content.ts)
/content/reference/
# Downloaded TypeDoc dumps under spec/reference/<lib>/<ver>/. Regenerated by
# `cd apps/docs/spec && make download.tsdoc.v2`. Hand-authored files in the
# same folders (config.json, partials/) stay tracked.
/spec/reference/*/*/*.json
!/spec/reference/*/*/config.json
# Sentry Config File
.env.sentry-build-plugin
+1 -1
View File
@@ -18,7 +18,7 @@ For a complete run-down on how all of our tools work together, see the main DEVE
1. Follow the steps outlined in the Local Development section of the main [DEVELOPERS.md](https://github.com/supabase/supabase/blob/master/DEVELOPERS.md)
2. If you work at Supabase, run `dev:secrets:pull` to pull down the internal environment variables. If you're a community member, create a `.env` file and add this line to it: `NEXT_PUBLIC_IS_PLATFORM=false`
3. Start the local docs site by navigating to `/apps/docs` and running `npm run dev`
3. Start the local docs site by navigating to `/apps/docs` and running `pnpm run dev`
4. Visit http://localhost:3001/docs in your browser - don't forget to append the `/docs` to the end
5. Your local site should look exactly like [https://supabase.com/docs](https://supabase.com/docs)
-15
View File
@@ -6,21 +6,6 @@ Supabase Reference Docs
If you are a maintainer of any tools in the Supabase ecosystem, you can use this site to provide documentation for the tools & libraries that you maintain.
## Versioning
All tools have versioned docs, which are kept in separate folders. For example, the CLI has the following folders and files:
- `cli`: the "next" release.
- `cli_spec`: contains the DocSpec for the "next" release (see below).
- `cli_versioned_docs`: a version of the documentation for every release (including the most current version).
- `cli_versioned_sidebars`: a version of the sidebar for every release (including the most current version).
When you release a new version of a tool, you should also release a new version of the docs. You can do this via the command line. For example, if you just released the CLI version `1.0.1`:
```
npm run cli:version 1.0.1
```
## DocSpec
We use documentation specifications which can be used to generate human-readable docs.
+24 -14
View File
@@ -1,11 +1,3 @@
import { toHtml } from 'hast-util-to-html'
import { fromMarkdown } from 'mdast-util-from-markdown'
import { mdxFromMarkdown } from 'mdast-util-mdx'
import { toHast } from 'mdast-util-to-hast'
import { mdxjs } from 'micromark-extension-mdxjs'
import { notFound } from 'next/navigation'
import { visit } from 'unist-util-visit'
import { REFERENCES } from '~/content/navigation.references'
import {
getFlattenedSections,
@@ -16,6 +8,13 @@ import { getRefMarkdown } from '~/features/docs/Reference.mdx'
import type { MethodTypes, VariableTypes } from '~/features/docs/Reference.typeSpec'
import type { AbbrevApiReferenceSection } from '~/features/docs/Reference.utils'
import { BASE_PATH } from '~/lib/constants'
import { toHtml } from 'hast-util-to-html'
import { fromMarkdown } from 'mdast-util-from-markdown'
import { mdxFromMarkdown } from 'mdast-util-mdx'
import { toHast } from 'mdast-util-to-hast'
import { mdxjs } from 'micromark-extension-mdxjs'
import { notFound } from 'next/navigation'
import { visit } from 'unist-util-visit'
export async function GET(request: Request) {
const url = new URL(request.url)
@@ -138,7 +137,7 @@ async function functionDetails(
let types: MethodTypes | VariableTypes | undefined
if (libraryMeta.typeSpec && '$ref' in fn) {
types = await getTypeSpec(fn['$ref'] as string)
types = await getTypeSpec(lib, version ?? libraryMeta.versions[0], fn['$ref'] as string)
}
const fullDescription = [
@@ -151,7 +150,7 @@ async function functionDetails(
.join('')
const parameters = parametersToHtml(fn, types)
const examples = examplesToHtml(fn)
const examples = examplesToHtml(fn, types)
return fullDescription + parameters + examples
}
@@ -219,13 +218,24 @@ function parametersToHtml(fn: any, types: MethodTypes | VariableTypes | undefine
return result
}
function examplesToHtml(fn: any) {
if (!fn.examples || fn.examples.length === 0) return ''
function examplesToHtml(fn: any, types?: MethodTypes | VariableTypes) {
// Prefer hand-authored YAML/JSON examples on the section entry; fall back to
// TSDoc-extracted `@example` blocks on the method's normalised comment. The
// page renderer in `Reference.sections.tsx` does the same merge, so the
// crawler stays consistent with what a browser sees.
const examples =
Array.isArray(fn.examples) && fn.examples.length > 0
? fn.examples
: (types?.comment?.examples ?? [])
if (examples.length === 0) return ''
let result = '<h2 id="examples">Examples</h2>'
result += fn.examples
.map((example) => `<h3>${example.name ?? ''}</h3>` + mdxToHtml(example.code ?? ''))
result += examples
.map(
(example: { name?: string; code?: string }) =>
`<h3>${example.name ?? ''}</h3>` + mdxToHtml(example.code ?? '')
)
.join('')
return result
@@ -15,7 +15,7 @@ import { linkTransform, type UrlTransformFunction } from '~/lib/mdx/plugins/rehy
import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition'
import { removeTitle } from '~/lib/mdx/plugins/remarkRemoveTitle'
import remarkPyMdownTabs from '~/lib/mdx/plugins/remarkTabs'
import { getGitHubFileContents, octokit } from '~/lib/octokit'
import { getGitHubFileContents } from '~/lib/octokit'
import type { SerializeOptions } from '~/types/next-mdx-remote-serialize'
import { isFeatureEnabled } from 'common'
import matter from 'gray-matter'
@@ -29,74 +29,10 @@ import { Admonition } from 'ui-patterns'
// We fetch these docs at build time from an external repo
const org = 'supabase'
const repo = 'wrappers'
const branch = 'main'
const docsDir = 'docs/catalog'
const externalSite = 'https://supabase.github.io/wrappers'
type TagQueryResponse = {
repository: {
refs: {
nodes:
| {
name: string
}[]
| null
pageInfo: {
hasNextPage: boolean
endCursor: string | null
}
}
}
}
const tagQuery = `
query TagQuery($owner: String!, $name: String!, $after: String) {
repository(owner: $owner, name: $name) {
refs(
refPrefix: "refs/tags/",
orderBy: {
field: TAG_COMMIT_DATE,
direction: DESC
},
first: 5,
after: $after
) {
nodes {
name
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`
async function getLatestRelease(after: string | null = null) {
try {
const {
repository: {
refs: {
nodes,
pageInfo: { hasNextPage, endCursor },
},
},
} = await octokit().graphql<TagQueryResponse>(tagQuery, {
owner: org,
name: repo,
after,
})
return (
nodes?.find((node) => node?.name?.match(/^docs_v\d+\.\d+\.\d+/))?.name ??
(hasNextPage && endCursor ? await getLatestRelease(endCursor) : null)
)
} catch (error) {
console.error(`Error fetching release tags for wrappers federated pages: ${error}`)
return null
}
}
// Each external docs page is mapped to a local page
const pageMap = [
{
@@ -123,6 +59,22 @@ const pageMap = [
},
remoteFile: 'bigquery.md',
},
{
slug: 'cal',
meta: {
title: 'Cal.com',
dashboardIntegrationPath: 'cal_wrapper',
},
remoteFile: 'cal.md',
},
{
slug: 'calendly',
meta: {
title: 'Calendly',
dashboardIntegrationPath: 'calendly_wrapper',
},
remoteFile: 'calendly.md',
},
{
slug: 'clerk',
meta: {
@@ -139,6 +91,14 @@ const pageMap = [
},
remoteFile: 'clickhouse.md',
},
{
slug: 'cloudflare-d1',
meta: {
title: 'Cloudflare D1',
dashboardIntegrationPath: 'cfd1_wrapper',
},
remoteFile: 'cfd1.md',
},
{
slug: 'cognito',
meta: {
@@ -151,9 +111,18 @@ const pageMap = [
slug: 'duckdb',
meta: {
title: 'DuckDB',
dashboardIntegrationPath: undefined,
},
remoteFile: 'duckdb.md',
},
{
slug: 'dynamodb',
meta: {
title: 'AWS DynamoDB',
dashboardIntegrationPath: undefined,
},
remoteFile: 'dynamodb.md',
},
{
slug: 'firebase',
meta: {
@@ -162,6 +131,22 @@ const pageMap = [
},
remoteFile: 'firebase.md',
},
{
slug: 'gravatar',
meta: {
title: 'Gravatar',
dashboardIntegrationPath: undefined,
},
remoteFile: 'gravatar.md',
},
{
slug: 'hubspot',
meta: {
title: 'HubSpot',
dashboardIntegrationPath: 'hubspot_wrapper',
},
remoteFile: 'hubspot.md',
},
{
slug: 'iceberg',
meta: {
@@ -170,6 +155,14 @@ const pageMap = [
},
remoteFile: 'iceberg.md',
},
{
slug: 'infura',
meta: {
title: 'Infura',
dashboardIntegrationPath: undefined,
},
remoteFile: 'infura.md',
},
{
slug: 'logflare',
meta: {
@@ -186,6 +179,14 @@ const pageMap = [
},
remoteFile: 'mssql.md',
},
{
slug: 'mysql',
meta: {
title: 'MySQL',
dashboardIntegrationPath: undefined,
},
remoteFile: 'mysql.md',
},
{
slug: 'notion',
meta: {
@@ -194,6 +195,22 @@ const pageMap = [
},
remoteFile: 'notion.md',
},
{
slug: 'openapi',
meta: {
title: 'OpenAPI',
dashboardIntegrationPath: undefined,
},
remoteFile: 'openapi.md',
},
{
slug: 'orb',
meta: {
title: 'Orb',
dashboardIntegrationPath: 'orb_wrapper',
},
remoteFile: 'orb.md',
},
{
slug: 'paddle',
meta: {
@@ -226,6 +243,22 @@ const pageMap = [
},
remoteFile: 's3vectors.md',
},
{
slug: 'shopify',
meta: {
title: 'Shopify',
dashboardIntegrationPath: undefined,
},
remoteFile: 'shopify.md',
},
{
slug: 'slack',
meta: {
title: 'Slack',
dashboardIntegrationPath: undefined,
},
remoteFile: 'slack.md',
},
{
slug: 'snowflake',
meta: {
@@ -345,21 +378,16 @@ const getContent = async (params: Params) => {
let remoteFile: string
;({ remoteFile, meta } = federatedPage)
const tag = await getLatestRelease()
if (!tag) {
throw new Error('No latest release found for federated wrappers pages')
}
editLink = `${org}/${repo}/blob/${tag}/${docsDir}/${remoteFile}`
editLink = `${org}/${repo}/blob/${branch}/${docsDir}/${remoteFile}`
let rawContent = await getGitHubFileContents({
org,
repo,
path: `${docsDir}/${remoteFile}`,
branch: tag,
branch,
})
assetsBaseUrl = `https://raw.githubusercontent.com/${org}/${repo}/${tag}/docs/assets/`
assetsBaseUrl = `https://raw.githubusercontent.com/${org}/${repo}/${branch}/docs/assets/`
const { content: contentWithoutFrontmatter } = matter(rawContent)
content = removeRedundantH1(contentWithoutFrontmatter)
@@ -425,7 +453,7 @@ const urlTransform: UrlTransformFunction = (url) => {
}
const generateStaticParams = async () => {
if (!IS_DEV) {
if (IS_DEV) {
return []
}
@@ -1,5 +1,3 @@
import { type Metadata } from 'next'
import { TroubleshootingPreview } from '~/features/docs/Troubleshooting.ui'
import {
TroubleshootingFilter,
@@ -16,6 +14,7 @@ import { TROUBLESHOOTING_CONTAINER_ID } from '~/features/docs/Troubleshooting.ut
import { SidebarSkeleton } from '~/layouts/MainSkeleton'
import { PROD_URL } from '~/lib/constants'
import { getCustomContent } from '~/lib/custom-content/getCustomContent'
import { type Metadata } from 'next'
const { metadataTitle } = getCustomContent(['metadata:title'])
+1 -1
View File
@@ -31,7 +31,7 @@ const generateMetadata = async (_, parent: ResolvingMetadata): Promise<Metadata>
media: parentAlternates.media || undefined,
types: {
...(parentAlternates.types ?? {}),
'text/markdown': '/llms-full.txt',
'text/markdown': 'https://supabase.com/llms-full.txt',
},
}),
},
@@ -1,79 +0,0 @@
import { KJUR } from 'jsrsasign'
import { useState } from 'react'
import { Button } from 'ui'
import { CodeBlock } from 'ui-patterns/CodeBlock'
const JWT_HEADER = { alg: 'HS256', typ: 'JWT' }
const generateRandomString = (length: number) => {
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let result = ''
const MAX = Math.floor(256 / CHARS.length) * CHARS.length - 1
const randomUInt8Array = new Uint8Array(1)
for (let i = 0; i < length; i++) {
let randomNumber: number
do {
crypto.getRandomValues(randomUInt8Array)
randomNumber = randomUInt8Array[0]
} while (randomNumber > MAX)
result += CHARS[randomNumber % CHARS.length]
}
return result
}
const generateKeys = () => {
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const fiveYears = new Date(now.getFullYear() + 5, now.getMonth(), now.getDate())
const iat = Math.floor(today.valueOf() / 1000)
const exp = Math.floor(fiveYears.valueOf() / 1000)
const anonToken = { role: 'anon', iss: 'supabase', iat, exp }
const serviceToken = { role: 'service_role', iss: 'supabase', iat, exp }
const secret = generateRandomString(40)
const anonKey = KJUR.jws.JWS.sign(null, JWT_HEADER, anonToken, secret)
const serviceRoleKey = KJUR.jws.JWS.sign(null, JWT_HEADER, serviceToken, secret)
return { secret, anonKey, serviceRoleKey }
}
export default function JwtGeneratorSimple() {
const [keys, setKeys] = useState(generateKeys)
const regenerate = () => {
setKeys(generateKeys())
}
return (
<div className="border rounded-lg p-4">
<div className="grid mb-8">
<label htmlFor="secret">JWT_SECRET</label>
<CodeBlock language="bash" className="relative font-mono">
{keys.secret}
</CodeBlock>
</div>
<div className="grid mb-8">
<label htmlFor="anon">ANON_KEY</label>
<CodeBlock language="bash" className="relative font-mono">
{keys.anonKey}
</CodeBlock>
</div>
<div className="grid mb-8">
<label htmlFor="service">SERVICE_ROLE_KEY</label>
<CodeBlock language="bash" className="relative font-mono">
{keys.serviceRoleKey}
</CodeBlock>
</div>
<Button type="primary" onClick={regenerate}>
Generate new
</Button>
</div>
)
}
@@ -1,16 +0,0 @@
'use client'
import dynamic from 'next/dynamic'
import { Suspense } from 'react'
const DynamicJwtGeneratorSimple = dynamic(() => import('./JwtGeneratorSimple'), { ssr: false })
const JwtGeneratorSimple = () => {
return (
<Suspense fallback={<div>Loading...</div>}>
<DynamicJwtGeneratorSimple />
</Suspense>
)
}
export { JwtGeneratorSimple }
@@ -170,7 +170,7 @@ export const MenuItem = React.forwardRef<
ref={ref}
className={cn(
'group/menu-item flex items-center gap-2',
'w-full flex h-8 items-center text-foreground-light text-sm hover:text-foreground select-none rounded-md py-2 leading-none no-underline outline-hidden! focus-visible:ring-2 focus-visible:ring-foreground-lighter focus-visible:text-foreground',
'w-full flex h-8 items-center text-foreground-light text-sm hover:text-foreground select-none rounded-md p-2 leading-none no-underline outline-hidden! focus-visible:ring-2 focus-visible:ring-foreground-lighter focus-visible:text-foreground',
className
)}
{...props}
@@ -755,6 +755,12 @@ export const auth: NavMenuConstant = {
enabled: allAuthProvidersEnabled,
},
{
name: 'Passkey',
url: '/guides/auth/passkeys',
enabled: allAuthProvidersEnabled,
},
{
name: 'Social Login (OAuth)',
url: '/guides/auth/social-login',
@@ -1325,76 +1331,130 @@ export const database: NavMenuConstant = {
url: '/guides/database/extensions/wrappers/overview' as `/${string}`,
},
{
name: 'Connecting to Auth0',
url: '/guides/database/extensions/wrappers/auth0' as `/${string}`,
},
{
name: 'Connecting to Airtable',
url: '/guides/database/extensions/wrappers/airtable' as `/${string}`,
},
{
name: 'Connecting to AWS Cognito',
url: '/guides/database/extensions/wrappers/cognito' as `/${string}`,
},
{
name: 'Connecting to AWS S3',
url: '/guides/database/extensions/wrappers/s3' as `/${string}`,
},
{
name: 'Connecting to AWS S3 Vectors',
url: '/guides/database/extensions/wrappers/s3_vectors' as `/${string}`,
},
{
name: 'Connecting to BigQuery',
url: '/guides/database/extensions/wrappers/bigquery' as `/${string}`,
},
{
name: 'Connecting to Clerk',
url: '/guides/database/extensions/wrappers/clerk' as `/${string}`,
},
{
name: 'Connecting to ClickHouse',
url: '/guides/database/extensions/wrappers/clickhouse' as `/${string}`,
},
{
name: 'Connecting to DuckDB',
url: '/guides/database/extensions/wrappers/duckdb' as `/${string}`,
},
{
name: 'Connecting to Firebase',
url: '/guides/database/extensions/wrappers/firebase' as `/${string}`,
},
{
name: 'Connecting to Iceberg',
url: '/guides/database/extensions/wrappers/iceberg' as `/${string}`,
},
{
name: 'Connecting to Logflare',
url: '/guides/database/extensions/wrappers/logflare' as `/${string}`,
},
{
name: 'Connecting to MSSQL',
url: '/guides/database/extensions/wrappers/mssql' as `/${string}`,
},
{
name: 'Connecting to Notion',
url: '/guides/database/extensions/wrappers/notion' as `/${string}`,
},
{
name: 'Connecting to Paddle',
url: '/guides/database/extensions/wrappers/paddle' as `/${string}`,
},
{
name: 'Connecting to Redis',
url: '/guides/database/extensions/wrappers/redis' as `/${string}`,
},
{
name: 'Connecting to Snowflake',
url: '/guides/database/extensions/wrappers/snowflake' as `/${string}`,
},
{
name: 'Connecting to Stripe',
url: '/guides/database/extensions/wrappers/stripe' as `/${string}`,
name: 'Sources',
url: '/guides/database/extensions/wrappers/overview' as `/${string}`,
items: [
{
name: 'Airtable',
url: '/guides/database/extensions/wrappers/airtable' as `/${string}`,
},
{
name: 'Auth0',
url: '/guides/database/extensions/wrappers/auth0' as `/${string}`,
},
{
name: 'AWS Cognito',
url: '/guides/database/extensions/wrappers/cognito' as `/${string}`,
},
{
name: 'AWS DynamoDB',
url: '/guides/database/extensions/wrappers/dynamodb' as `/${string}`,
},
{
name: 'AWS S3',
url: '/guides/database/extensions/wrappers/s3' as `/${string}`,
},
{
name: 'AWS S3 Vectors',
url: '/guides/database/extensions/wrappers/s3_vectors' as `/${string}`,
},
{
name: 'BigQuery',
url: '/guides/database/extensions/wrappers/bigquery' as `/${string}`,
},
{
name: 'Cal.com',
url: '/guides/database/extensions/wrappers/cal' as `/${string}`,
},
{
name: 'Calendly',
url: '/guides/database/extensions/wrappers/calendly' as `/${string}`,
},
{
name: 'Clerk',
url: '/guides/database/extensions/wrappers/clerk' as `/${string}`,
},
{
name: 'ClickHouse',
url: '/guides/database/extensions/wrappers/clickhouse' as `/${string}`,
},
{
name: 'Cloudflare D1',
url: '/guides/database/extensions/wrappers/cloudflare-d1' as `/${string}`,
},
{
name: 'DuckDB',
url: '/guides/database/extensions/wrappers/duckdb' as `/${string}`,
},
{
name: 'Firebase',
url: '/guides/database/extensions/wrappers/firebase' as `/${string}`,
},
{
name: 'Gravatar',
url: '/guides/database/extensions/wrappers/gravatar' as `/${string}`,
},
{
name: 'HubSpot',
url: '/guides/database/extensions/wrappers/hubspot' as `/${string}`,
},
{
name: 'Iceberg',
url: '/guides/database/extensions/wrappers/iceberg' as `/${string}`,
},
{
name: 'Infura',
url: '/guides/database/extensions/wrappers/infura' as `/${string}`,
},
{
name: 'Logflare',
url: '/guides/database/extensions/wrappers/logflare' as `/${string}`,
},
{
name: 'MSSQL',
url: '/guides/database/extensions/wrappers/mssql' as `/${string}`,
},
{
name: 'MySQL',
url: '/guides/database/extensions/wrappers/mysql' as `/${string}`,
},
{
name: 'Notion',
url: '/guides/database/extensions/wrappers/notion' as `/${string}`,
},
{
name: 'OpenAPI',
url: '/guides/database/extensions/wrappers/openapi' as `/${string}`,
},
{
name: 'Orb',
url: '/guides/database/extensions/wrappers/orb' as `/${string}`,
},
{
name: 'Paddle',
url: '/guides/database/extensions/wrappers/paddle' as `/${string}`,
},
{
name: 'Redis',
url: '/guides/database/extensions/wrappers/redis' as `/${string}`,
},
{
name: 'Shopify',
url: '/guides/database/extensions/wrappers/shopify' as `/${string}`,
},
{
name: 'Slack',
url: '/guides/database/extensions/wrappers/slack' as `/${string}`,
},
{
name: 'Snowflake',
url: '/guides/database/extensions/wrappers/snowflake' as `/${string}`,
},
{
name: 'Stripe',
url: '/guides/database/extensions/wrappers/stripe' as `/${string}`,
},
],
},
],
},
@@ -1511,6 +1571,10 @@ export const api: NavMenuConstant = {
{ name: 'Generating TypeScript Types', url: '/guides/api/rest/generating-types' },
{ name: 'Generating Python Types', url: '/guides/api/rest/generating-python-types' },
{ name: 'Error Codes', url: '/guides/api/rest/postgrest-error-codes' },
{
name: 'Handling Errors in supabase-js',
url: '/guides/api/handling-errors-in-supabase-js',
},
],
},
{
@@ -1783,6 +1847,10 @@ export const functions: NavMenuConstant = {
name: 'Image Transformation & Optimization',
url: '/guides/functions/examples/image-manipulation' as `/${string}`,
},
{
name: 'Resumable WebSockets with replay',
url: '/guides/functions/examples/resumable-websockets' as `/${string}`,
},
],
},
{
@@ -2522,6 +2590,7 @@ export const security: NavMenuConstant = {
},
{ name: 'Row Level Security', url: '/guides/database/postgres/row-level-security' },
{ name: 'Securing your API', url: '/guides/api/securing-your-api' },
{ name: 'Securing your npm installs', url: '/guides/security/npm-security' },
],
},
],
@@ -2670,6 +2739,10 @@ export const platform: NavMenuConstant = {
name: 'Network Restrictions',
url: '/guides/platform/network-restrictions' as `/${string}`,
},
{
name: 'Temporary Access',
url: '/guides/platform/temporary-access' as `/${string}`,
},
{ name: 'Performance Tuning', url: '/guides/platform/performance' as `/${string}` },
{ name: 'SSL Enforcement', url: '/guides/platform/ssl-enforcement' as `/${string}` },
{
@@ -2780,6 +2853,18 @@ export const platform: NavMenuConstant = {
name: 'Branching',
url: '/guides/platform/manage-your-usage/branching' as `/${string}`,
},
{
name: 'Logs',
url: '/guides/platform/manage-your-usage/logs' as `/${string}`,
},
{
name: 'Logs Ingest',
url: '/guides/platform/manage-your-usage/logs-ingest' as `/${string}`,
},
{
name: 'Logs Query',
url: '/guides/platform/manage-your-usage/logs-query' as `/${string}`,
},
{
name: 'Log Drains',
url: '/guides/platform/manage-your-usage/log-drains' as `/${string}`,
@@ -2855,6 +2940,10 @@ export const telemetry: NavMenuConstant = {
name: 'Advanced log filtering',
url: '/guides/telemetry/advanced-log-filtering' as `/${string}`,
},
{
name: 'Logs field reference',
url: '/guides/telemetry/log-field-reference' as `/${string}`,
},
{
name: 'Log drains',
url: '/guides/telemetry/log-drains' as `/${string}`,
@@ -21,6 +21,7 @@ export interface ComboBoxOption {
id: string
value: string
displayName: string
disabled?: boolean
}
export function ComboBox<Opt extends ComboBoxOption>({
@@ -131,6 +132,7 @@ export function ComboBox<Opt extends ComboBoxOption>({
{options.map((option) => (
<CommandItem
key={option.id}
disabled={option.disabled}
value={option.value}
onSelect={(selectedValue: string) => {
setOpen(false)
@@ -138,10 +138,14 @@ function OrgProjectSelector() {
: (projects!
.map((project) => {
const organization = organizations!.find((org) => org.id === project.organization_id)!
const paused = isProjectPaused(project)
return {
id: project.ref,
value: toOrgProjectValue(organization, project),
displayName: toDisplayNameOrgProject(organization, project),
displayName: paused
? `${toDisplayNameOrgProject(organization, project)} (paused)`
: toDisplayNameOrgProject(organization, project),
disabled: paused,
}
})
.filter(Boolean) as ComboBoxOption[]),
@@ -162,10 +166,14 @@ function OrgProjectSelector() {
if (storedOrg && storedProject && storedProject.organization_id === storedOrg.id) {
setSelectedOrgProject(storedOrg, storedProject)
} else if (projects!.length > 0) {
const firstProject = projects![0]
const matchingOrg = organizations!.find((org) => org.id === firstProject.organization_id)
if (matchingOrg) setSelectedOrgProject(matchingOrg, firstProject)
} else {
const firstActiveProject = projects!.find((project) => !isProjectPaused(project))
if (firstActiveProject) {
const matchingOrg = organizations!.find(
(org) => org.id === firstActiveProject.organization_id
)
if (matchingOrg) setSelectedOrgProject(matchingOrg, firstActiveProject)
}
}
}
}, [organizations, projects, selectedOrg, selectedProject, setSelectedOrgProject, stateSummary])
@@ -0,0 +1,9 @@
<Price price="0.50" /> per GB. You are only charged for usage exceeding your subscription plan's
quota.
| Plan | Quota | Over-Usage per GB |
| ---------- | ------ | ---------------------- |
| Free | 5 GB | - |
| Pro | 5 GB | <Price price="0.50" /> |
| Team | 5 GB | <Price price="0.50" /> |
| Enterprise | Custom | Custom |
@@ -0,0 +1,9 @@
<Price price="0.002" /> per GB. You are only charged for usage exceeding your subscription plan's
quota.
| Plan | Quota | Over-Usage per GB |
| ---------- | -------- | ----------------------- |
| Free | 1,000 GB | - |
| Pro | 1,000 GB | <Price price="0.002" /> |
| Team | 1,000 GB | <Price price="0.002" /> |
| Enterprise | Custom | Custom |
@@ -2,10 +2,10 @@
Pricing depends on the recovery retention period, which determines how many days back you can restore data to any chosen point of up to seconds in granularity.
| Recovery Retention Period in Days | Hourly Price USD | Monthly Price USD |
| --------------------------------- | ----------------------- | --------------------- |
| 7 | <Price price="0.137" /> | <Price price="100" /> |
| 14 | <Price price="0.274" /> | <Price price="200" /> |
| 28 | <Price price="0.55" /> | <Price price="400" /> |
| Recovery Retention Period in Days | Hourly Price USD | Monthly Price USD |
| --------------------------------- | ----------------------- | ---------------------- |
| 7 | <Price price="0.137" /> | ~<Price price="100" /> |
| 14 | <Price price="0.274" /> | ~<Price price="200" /> |
| 28 | <Price price="0.55" /> | ~<Price price="400" /> |
For a detailed breakdown of how charges are calculated, refer to [Manage Point-in-Time Recovery usage](/docs/guides/platform/manage-your-usage/point-in-time-recovery).
@@ -1,9 +1,9 @@
<Price price="0.00002919" /> per GB-Hr (<Price price="0.021" /> per GB per month). You are only
<Price price="0.00002919" /> per GB-Hr (<Price price="0.0213" /> per GB per month). You are only
charged for usage exceeding your subscription plan's quota.
| Plan | Quota in GB | Over-Usage per GB | Quota in GB-Hrs | Over-Usage per GB-Hr |
| ---------- | ----------- | ----------------------- | --------------- | ---------------------------- |
| Free | 1 | - | 744 | - |
| Pro | 100 | <Price price="0.021" /> | 74,400 | <Price price="0.00002919" /> |
| Team | 100 | <Price price="0.021" /> | 74,400 | <Price price="0.00002919" /> |
| Enterprise | Custom | Custom | Custom | Custom |
| Plan | Quota in GB | Over-Usage per GB | Quota in GB-Hrs | Over-Usage per GB-Hr |
| ---------- | ----------- | ------------------------ | --------------- | ---------------------------- |
| Free | 1 | - | 744 | - |
| Pro | 100 | <Price price="0.0213" /> | 74,400 | <Price price="0.00002919" /> |
| Team | 100 | <Price price="0.0213" /> | 74,400 | <Price price="0.00002919" /> |
| Enterprise | Custom | Custom | Custom | Custom |
@@ -49,10 +49,10 @@ create trigger on_auth_user_created
insert into storage.buckets (id, name)
values ('avatars', 'avatars');
-- Set up access controls for storage.
-- Set up access controls for storage. Allows downloading object with public key
-- See https://supabase.com/docs/guides/storage/security/access-control#policy-examples for more details.
create policy "Avatar images are publicly accessible." on storage.objects
for select using (bucket_id = 'avatars');
for select using (bucket_id = 'avatars' and storage.allow_any_operation(array['object.get_authenticated_info', 'object.get_authenticated']));
create policy "Anyone can upload an avatar." on storage.objects
for insert with check (bucket_id = 'avatars');
+130 -42
View File
@@ -31,15 +31,72 @@ Check out all of the AI [templates and examples](https://github.com/supabase/sup
{/* <!-- vale off --> */}
<div className="grid md:grid-cols-12 gap-4 not-prose">
{aiExamples.map((x) => (
<div className="col-span-4" key={x.href}>
<Link href={x.href} passHref>
<GlassPanel icon={'/docs/img/icons/github-icon'} hasLightIcon={true} title={x.name}>
{x.description}
</GlassPanel>
</Link>
</div>
))}
<div className="col-span-4">
<Link href="/guides/ai/examples/headless-vector-search" passHref>
<GlassPanel
icon={'/docs/img/icons/github-icon'}
hasLightIcon={true}
title="Headless Vector Search"
>
A toolkit to perform vector similarity search on your knowledge base embeddings.
</GlassPanel>
</Link>
</div>
<div className="col-span-4">
<Link href="/guides/ai/examples/image-search-openai-clip" passHref>
<GlassPanel
icon={'/docs/img/icons/github-icon'}
hasLightIcon={true}
title="Image Search with OpenAI CLIP"
>
Implement image search with the OpenAI CLIP Model and Supabase Vector.
</GlassPanel>
</Link>
</div>
<div className="col-span-4">
<Link href="/guides/ai/examples/huggingface-image-captioning" passHref>
<GlassPanel
icon={'/docs/img/icons/github-icon'}
hasLightIcon={true}
title="Hugging Face inference"
>
Generate image captions using Hugging Face.
</GlassPanel>
</Link>
</div>
<div className="col-span-4">
<Link href="/guides/ai/examples/openai" passHref>
<GlassPanel
icon={'/docs/img/icons/github-icon'}
hasLightIcon={true}
title="OpenAI completions"
>
Generate GPT text completions using OpenAI in Edge Functions.
</GlassPanel>
</Link>
</div>
<div className="col-span-4">
<Link href="/guides/ai/examples/building-chatgpt-plugins" passHref>
<GlassPanel
icon={'/docs/img/icons/github-icon'}
hasLightIcon={true}
title="Building ChatGPT Plugins"
>
Use Supabase as a Retrieval Store for your ChatGPT plugin.
</GlassPanel>
</Link>
</div>
<div className="col-span-4">
<Link href="/guides/ai/examples/nextjs-vector-search" passHref>
<GlassPanel
icon={'/docs/img/icons/github-icon'}
hasLightIcon={true}
title="Vector search with Next.js and OpenAI"
>
Learn how to build a ChatGPT-style doc search powered by Next.js, OpenAI, and Supabase.
</GlassPanel>
</Link>
</div>
</div>
{/* <!-- vale on --> */}
@@ -49,13 +106,45 @@ Check out all of the AI [templates and examples](https://github.com/supabase/sup
{/* <!-- vale off --> */}
<div className="grid md:grid-cols-12 gap-4 not-prose">
{aiIntegrations.map((x) => (
<div className="col-span-4" key={x.href}>
<Link href={x.href} passHref>
<GlassPanel title={x.name}>{x.description}</GlassPanel>
</Link>
</div>
))}
<div className="col-span-4">
<Link href="/guides/ai/examples/building-chatgpt-plugins" passHref>
<GlassPanel title="OpenAI">
OpenAI is an AI research and deployment company. Supabase provides a simple way to use
OpenAI in your applications.
</GlassPanel>
</Link>
</div>
<div className="col-span-4">
<Link href="/guides/ai/integrations/amazon-bedrock" passHref>
<GlassPanel title="Amazon Bedrock">
A fully managed service that offers a choice of high-performing foundation models from
leading AI companies.
</GlassPanel>
</Link>
</div>
<div className="col-span-4">
<Link href="/guides/ai/hugging-face" passHref>
<GlassPanel title="Hugging Face">
Hugging Face is an open-source provider of NLP technologies. Supabase provides a simple way
to use Hugging Face's models in your applications.
</GlassPanel>
</Link>
</div>
<div className="col-span-4">
<Link href="/guides/ai/langchain" passHref>
<GlassPanel title="LangChain">
LangChain is a language-agnostic, open-source, and self-hosted API for text translation,
summarization, and sentiment analysis.
</GlassPanel>
</Link>
</div>
<div className="col-span-4">
<Link href="/guides/ai/integrations/llamaindex" passHref>
<GlassPanel title="LlamaIndex">
LlamaIndex is a data framework for your LLM applications.
</GlassPanel>
</Link>
</div>
</div>
{/* <!-- vale on --> */}
@@ -65,32 +154,31 @@ Check out all of the AI [templates and examples](https://github.com/supabase/sup
{/* <!-- vale off --> */}
<div className="grid md:grid-cols-12 gap-4 not-prose">
{[
{
name: 'Berri AI Boosts Productivity by Migrating from AWS RDS to Supabase with pgvector',
description:
'Learn how Berri AI overcame challenges with self-hosting their vector database on AWS RDS and successfully migrated to Supabase.',
href: 'https://supabase.com/customers/berriai',
},
{
name: 'Firecrawl switches from Pinecone to Supabase for Postgres vector embeddings',
description:
'How Firecrawl boosts efficiency and accuracy of chat powered search for documentation using Supabase with pgvector',
href: 'https://supabase.com/customers/firecrawl',
},
{
name: 'Markprompt: GDPR-Compliant AI Chatbots for Docs and Websites',
description:
"AI-powered chatbot platform, Markprompt, empowers developers to deliver efficient and GDPR-compliant prompt experiences on top of their content, by leveraging Supabase's secure and privacy-focused database and authentication solutions",
href: 'https://supabase.com/customers/markprompt',
},
].map((x) => (
<div className="col-span-4" key={x.href}>
<Link href={x.href} passHref>
<GlassPanel title={x.name}>{x.description}</GlassPanel>
</Link>
</div>
))}
<div className="col-span-4">
<Link href="https://supabase.com/customers/berriai" passHref>
<GlassPanel title="Berri AI Boosts Productivity by Migrating from AWS RDS to Supabase with pgvector">
Learn how Berri AI overcame challenges with self-hosting their vector database on AWS RDS
and successfully migrated to Supabase.
</GlassPanel>
</Link>
</div>
<div className="col-span-4">
<Link href="https://supabase.com/customers/firecrawl" passHref>
<GlassPanel title="Firecrawl switches from Pinecone to Supabase for Postgres vector embeddings">
How Firecrawl boosts efficiency and accuracy of chat powered search for documentation using
Supabase with pgvector
</GlassPanel>
</Link>
</div>
<div className="col-span-4">
<Link href="https://supabase.com/customers/markprompt" passHref>
<GlassPanel title="Markprompt: GDPR-Compliant AI Chatbots for Docs and Websites">
AI-powered chatbot platform, Markprompt, empowers developers to deliver efficient and
GDPR-compliant prompt experiences on top of their content, by leveraging Supabase's secure
and privacy-focused database and authentication solutions
</GlassPanel>
</Link>
</div>
</div>
{/* <!-- vale on --> */}
@@ -65,7 +65,7 @@ Deno.serve(async (req) => {
// Supabase API URL - env var exported by default when deployed.
Deno.env.get('SUPABASE_URL') ?? '',
// Supabase API SECRET KEY - env var exported by default when deployed.
Deno.env.get(SUPABASE_SECRET_KEYS['default']) ?? ''
SUPABASE_SECRET_KEYS['default'] ?? ''
)
// Construct image url from storage
@@ -0,0 +1,145 @@
---
id: handling-errors-in-supabase-js
title: 'Handling errors in `supabase-js`'
subtitle: 'Read `error.hint` first — Postgres often tells you the exact fix. Log the full error so you actually see it.'
---
Every `supabase-js` call returns a `{ data, error }` pair instead of throwing. When something fails, the single most useful field on `error` is usually `hint` — Postgres returns the _fix_, not just a description of the problem. Logging only `error.message` hides it.
## Usage of `message` and `hint` properties
Consider a `42501` permission-denied error on a table where default `GRANT`s have been revoked from `anon`:
```
message: "permission denied for table users"
hint: "Grant the required privileges to the current role with: GRANT SELECT ON public.users TO anon;"
```
The `message` exposes the error reason, and `hint` gives you the literal SQL statement to run in the dashboard SQL editor to fix it.
The same pattern shows up across many Postgres errors — missing column? `hint` suggests the column name you probably meant. Type mismatch? `hint` shows the expected type. Whenever Postgres knows the fix, it puts it in `hint`.
<Admonition type="tip">Log the full `error` object, not just `error.message`.</Admonition>
## The recommended pattern
Read `{ data, error }` from the response, check `error`, log the whole object, and return early.
```ts
const { data, error } = await supabase.from('users').select()
if (error) {
console.error(error)
return
}
```
In the case of a permission-denied error, the response body will look like this:
```json
{
"error": {
"code": "42501",
"message": "permission denied for table users",
"details": null,
"hint": "Grant the required privileges to the current role with: GRANT SELECT ON public.users TO anon;"
},
"status": 401,
"statusText": "Unauthorized"
}
```
`postgrest-js` passes the body through verbatim, so `error.hint` is the exact string Postgres produced. Treat it as the answer the database is giving you, not as a suggestion to file away.
## The `PostgrestError` fields, by usefulness
Database calls (`select`, `insert`, `update`, `upsert`, `delete`, `rpc`) return a `PostgrestError` with four fields. Read them in roughly this order:
| Field | Read it when |
| --------- | ------------------------------------------------------------------------------------------------------------------ |
| `hint` | Always check first. When Postgres includes one, it's the actionable fix (a `GRANT` to run, a column name, a type). |
| `code` | When branching in code. Codes are stable across versions; `message` text isn't. |
| `details` | When `hint` and `message` aren't enough. Often contains the offending value, key, or row. |
| `message` | As the human summary. Useful in UI strings, less useful for debugging. |
A full list of PostgREST error codes is in the [Error Codes reference](/guides/api/rest/postgrest-error-codes).
## Branch on `error.code`, not `error.message`
`error.code` is more reliable than `error.message` for programmatic branching: messages change between Postgres and PostgREST versions, but codes are stable.
```ts
const { data, error } = await supabase.from('users').select()
if (error) {
console.error(error)
if (error.code === '42501') {
// Permission denied. error.hint usually contains the GRANT to run.
}
return
}
```
## Errors from Auth, Storage, and Edge Functions
The same rule applies across the SDK — log the whole error object — but the shape differs by client.
### Auth
`AuthError` exposes `error.code` (e.g. `'invalid_credentials'`, `'email_not_confirmed'`) and `error.status`. Branch on `code`; log the whole thing.
```ts
const { data, error } = await supabase.auth.signInWithPassword({
email: 'example@email.com',
password: 'example-password',
})
if (error) {
console.error(error)
return
}
```
### Storage
`StorageError` exposes `error.statusCode` (HTTP status as a string) and a structured `error` name (e.g. `'Duplicate'`, `'NotFound'`).
```ts
const { data, error } = await supabase.storage
.from('avatars')
.upload('public/avatar1.png', avatarFile)
if (error) {
console.error(error)
return
}
```
### Edge Functions
Functions errors arrive as one of three subclasses. Narrow with `instanceof`; for `FunctionsHttpError`, parse the body to get the function's own error payload.
```ts
import { FunctionsFetchError, FunctionsHttpError, FunctionsRelayError } from '@supabase/supabase-js'
const { data, error } = await supabase.functions.invoke('hello')
if (error instanceof FunctionsHttpError) {
console.error('Function error', await error.context.json())
} else if (error) {
console.error(error)
}
```
### Realtime
The `subscribe()` callback receives a `status` and, on failure, an `err` argument. Log the whole `err` — its `cause` often holds the underlying reason.
```ts
supabase.channel('room1').subscribe((status, err) => {
if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
console.error(status, err)
}
})
```
## Related
- [PostgREST Error Codes](/guides/api/rest/postgrest-error-codes)
- [Automatic retries with `supabase-js`](/guides/api/automatic-retries-in-supabase-js)
- [Securing your API](/guides/api/securing-your-api)
@@ -145,10 +145,10 @@ That's it for the implicit flow.
If you're using PKCE flow, edit the Magic Link [email template](/docs/guides/auth/auth-email-templates) to send a token hash:
```html
<h2>Magic Link</h2>
<h2>Sign in to your account</h2>
<p>Follow this link to login:</p>
<p><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email">Log In</a></p>
<p>Use this link to sign in to your account:</p>
<p><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email">Sign in</a></p>
```
At the `/auth/confirm` endpoint, exchange the hash for the session:
+35 -33
View File
@@ -366,37 +366,39 @@ Outside of runtime errors, both HTTP Hooks and Postgres Hooks return timeout err
Each Hook description contains an example JSON Schema which you can use in conjunction with [JSON Schema Faker](https://json-schema-faker.js.org/) in order to generate a mock payload. For HTTP Hooks, you can also use [the Standard Webhooks Testing Tool](https://www.standardwebhooks.com/simulate) to simulate a request.
<div className="grid md:grid-cols-12 gap-4 not-prose">
{[
{
name: 'Custom Access Token',
description: 'Customize the access token issued by Supabase Auth',
href: '/guides/auth/auth-hooks/custom-access-token-hook',
},
{
name: 'Send SMS',
description: 'Use a custom SMS provider to send authentication messages',
href: '/guides/auth/auth-hooks/send-sms-hook',
},
{
name: 'Send Email',
description: 'Use a custom email provider to send authentication messages',
href: '/guides/auth/auth-hooks/send-email-hook',
},
{
name: 'MFA Verification',
description: 'Add additional checks to the MFA verification flow',
href: '/guides/auth/auth-hooks/mfa-verification-hook',
},
{
name: 'Password verification',
description: 'Add additional checks to the password verification flow',
href: '/guides/auth/auth-hooks/password-verification-hook',
},
].map((x) => (
<div className="col-span-4" key={x.href}>
<Link href={x.href} passHref>
<GlassPanel title={x.name}>{x.description}</GlassPanel>
</Link>
</div>
))}
<div className="col-span-4">
<Link href="/guides/auth/auth-hooks/custom-access-token-hook" passHref>
<GlassPanel title="Custom Access Token">
Customize the access token issued by Supabase Auth
</GlassPanel>
</Link>
</div>
<div className="col-span-4">
<Link href="/guides/auth/auth-hooks/send-sms-hook" passHref>
<GlassPanel title="Send SMS">
Use a custom SMS provider to send authentication messages
</GlassPanel>
</Link>
</div>
<div className="col-span-4">
<Link href="/guides/auth/auth-hooks/send-email-hook" passHref>
<GlassPanel title="Send Email">
Use a custom email provider to send authentication messages
</GlassPanel>
</Link>
</div>
<div className="col-span-4">
<Link href="/guides/auth/auth-hooks/mfa-verification-hook" passHref>
<GlassPanel title="MFA Verification">
Add additional checks to the MFA verification flow
</GlassPanel>
</Link>
</div>
<div className="col-span-4">
<Link href="/guides/auth/auth-hooks/password-verification-hook" passHref>
<GlassPanel title="Password verification">
Add additional checks to the password verification flow
</GlassPanel>
</Link>
</div>
</div>
@@ -12,14 +12,18 @@ Currently, Supabase Auth supports 2 strategies to link an identity to a user:
1. [Automatic Linking](#automatic-linking)
2. [Manual Linking](#manual-linking-beta)
<Admonition type="note" title="No identity linking for SSO accounts">
Users that signed up with [SAML SSO](/docs/guides/auth/sso/auth-sso-saml) will not be considered as targets for identity linking (automatic or manual) for security reasons.
</Admonition>
### Automatic linking
Supabase Auth automatically links identities with the same email address to a single user. This helps to improve the user experience when multiple OAuth login options are presented since the user does not need to remember which OAuth account they used to sign up with. When a new user signs in with OAuth, Supabase Auth will attempt to look for an existing user that uses the same email address. If a match is found, the new identity is linked to the user.
In order for automatic linking to correctly identify the user for linking, Supabase Auth needs to ensure that all user emails are unique. It would also be an insecure practice to automatically link an identity to a user with an unverified email address since that could lead to pre-account takeover attacks. To prevent this from happening, when a new identity can be linked to an existing user, Supabase Auth will remove any other unconfirmed identities linked to an existing user.
Users that signed up with [SAML SSO](/docs/guides/auth/sso/auth-sso-saml) will not be considered as targets for automatic linking.
### Manual linking (beta)
<Tabs
@@ -78,10 +78,10 @@ Alternatively, you can use the `supabase sso info --project-ref <your-project>`
User accounts and identities created via SSO differ from regular (email, phone, password, social login...) accounts in these ways:
- **No automatic linking.**
Each user account verified using a SSO identity provider will not be automatically linked to existing user accounts in the system. That is, if a user `valid.email@supabase.io` had signed up with a password, and then uses their company SSO login with your project, there will be two `valid.email@supabase.io` user accounts in the system.
- **No identity linking.**
Each user account verified using an SSO identity provider are not legible for [identity linking](/docs/guides/auth/auth-identity-linking) to existing user accounts for security reasons. That is, if a user `valid.email@supabase.io` had signed up with a password, and then uses their company SSO login with your project, there will be two `valid.email@supabase.io` user accounts in the system.
- **Emails are not necessarily unique.**
Given the behavior with no automatic linking, email addresses are no longer a unique identifier for a user account. Always use the user's UUID to correctly reference user accounts.
Given the behavior with no identity linking, email addresses are no longer a unique identifier for a user account. Always use the user's UUID to correctly reference user accounts.
- **Sessions may have a maximum duration.**
Depending on the configuration of the identity provider, a login session established with SSO may forcibly log out a user after a certain period of time.
+28 -29
View File
@@ -54,35 +54,34 @@ OAuth 2.1 Server works seamlessly with your existing Supabase Auth configuration
To enable OAuth 2.1 Server in your project, follow these guides:
<div className="grid md:grid-cols-12 gap-4 not-prose">
{[
{
name: 'Getting Started',
description:
'Enable OAuth 2.1, configure your authorization endpoint, and register your first client.',
href: '/guides/auth/oauth-server/getting-started',
},
{
name: 'OAuth Flows',
description: 'Detailed walkthrough of authorization code and refresh token flows.',
href: '/guides/auth/oauth-server/oauth-flows',
},
{
name: 'MCP Authentication',
description: 'Authenticate AI agents and LLM tools using Model Context Protocol.',
href: '/guides/auth/oauth-server/mcp-authentication',
},
{
name: 'Token Security & RLS',
description: 'Control data access with Row Level Security policies for OAuth clients.',
href: '/guides/auth/oauth-server/token-security',
},
].map((x) => (
<div className="col-span-6" key={x.href}>
<Link href={x.href} passHref>
<GlassPanel title={x.name}>{x.description}</GlassPanel>
</Link>
</div>
))}
<div className="col-span-6">
<Link href="/guides/auth/oauth-server/getting-started" passHref>
<GlassPanel title="Getting Started">
Enable OAuth 2.1, configure your authorization endpoint, and register your first client.
</GlassPanel>
</Link>
</div>
<div className="col-span-6">
<Link href="/guides/auth/oauth-server/oauth-flows" passHref>
<GlassPanel title="OAuth Flows">
Detailed walkthrough of authorization code and refresh token flows.
</GlassPanel>
</Link>
</div>
<div className="col-span-6">
<Link href="/guides/auth/oauth-server/mcp-authentication" passHref>
<GlassPanel title="MCP Authentication">
Authenticate AI agents and LLM tools using Model Context Protocol.
</GlassPanel>
</Link>
</div>
<div className="col-span-6">
<Link href="/guides/auth/oauth-server/token-security" passHref>
<GlassPanel title="Token Security & RLS">
Control data access with Row Level Security policies for OAuth clients.
</GlassPanel>
</Link>
</div>
</div>
## Resources
Loaded 100 of 1017 files, more files were not shown because too many files have changed in this diff. Show more