Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ You can also [self-host](https://supabase.com/docs/guides/hosting/overview) and
- [Storage](https://github.com/supabase/storage-api) a RESTful API for managing files in S3, with Postgres handling permissions.
- [pg_graphql](http://github.com/supabase/pg_graphql/) a PostgreSQL extension that exposes a GraphQL API.
- [postgres-meta](https://github.com/supabase/postgres-meta) is a RESTful API for managing your Postgres, allowing you to fetch tables, add roles, and run queries, etc.
- [Kong](https://github.com/Kong/kong) is a cloud-native API gateway.
- [Envoy](https://github.com/envoyproxy/envoy) is a cloud-native, high-performance edge and service proxy.

#### Client libraries

Expand Down
4 changes: 2 additions & 2 deletions apps/docs/content/guides/auth/architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ subtitle: 'The architecture behind Supabase Auth.'
There are four major layers to Supabase Auth:

1. [Client layer.](#client-layer) This can be one of the Supabase client SDKs, or manually made HTTP requests using the HTTP client of your choice.
1. Kong API gateway. This is shared between all Supabase products.
1. Envoy API gateway. This is shared between all Supabase products.
1. [Auth service](#auth-service) (formerly known as GoTrue).
1. [Postgres database.](#postgres) This is shared between all Supabase products.

<Image
alt="Diagram showing the architecture of Supabase. The Kong API gateway sits in front of 7 services: GoTrue, PostgREST, Realtime, Storage, pg_meta, Functions, and pg_graphql. All the services talk to a single Postgres instance."
alt="Diagram showing the architecture of Supabase. The Envoy API gateway sits in front of 7 services: GoTrue, PostgREST, Realtime, Storage, pg_meta, Functions, and pg_graphql. All the services talk to a single Postgres instance."
src={{
dark: '/docs/img/supabase-architecture.svg',
light: '/docs/img/supabase-architecture--light.svg',
Expand Down
14 changes: 7 additions & 7 deletions apps/docs/content/guides/getting-started/architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Our goal at Supabase is to make _all_ of Postgres easy to use. That doesn’t me
Each Supabase project consists of several tools:

<Image
alt="Diagram showing the architecture of Supabase. The Kong API gateway sits in front of 7 services: GoTrue, PostgREST, Realtime, Storage, pg_meta, Functions, and pg_graphql. All the services talk to a single Postgres instance."
alt="Diagram showing the architecture of Supabase. The Envoy API gateway sits in front of 7 services: GoTrue, PostgREST, Realtime, Storage, pg_meta, Functions, and pg_graphql. All the services talk to a single Postgres instance."
src={{
dark: '/docs/img/supabase-architecture.svg',
light: '/docs/img/supabase-architecture--light.svg',
Expand Down Expand Up @@ -112,14 +112,14 @@ A cloud-native, multi-tenant Postgres connection pooler.
- License: [Apache 2.0](https://github.com/supabase/supavisor/blob/main/LICENSE)
- Language: Elixir

### Kong (API gateway)
### Envoy (API gateway)

A cloud-native API gateway, built on top of NGINX.
A cloud-native, high-performance edge and service proxy.

- Official Docs: [docs.konghq.com](https://docs.konghq.com/)
- Source code: [github.com/kong/kong](https://github.com/kong/kong)
- License: [Apache 2.0](https://github.com/Kong/kong/blob/master/LICENSE)
- Language: Lua
- Official Docs: [envoyproxy.io](https://www.envoyproxy.io/docs/envoy/latest/)
- Source code: [github.com/envoyproxy/envoy](https://github.com/envoyproxy/envoy)
- License: [Apache 2.0](https://github.com/envoyproxy/envoy/blob/main/LICENSE)
- Language: C++

## Product principles

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/public/humans.txt
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ Supabase would not be possible without the work of some other amazing open sourc

- Postgres - https://www.postgresql.org/
- PostgREST - https://github.com/PostgREST
- Kong - https://github.com/kong/
- Envoy - https://github.com/envoyproxy/envoy
- Next.js - https://nextjs.org/
- Tailwind CSS - https://tailwindcss.com/
- shadcn/ui - https://ui.shadcn.com/
Expand Down
51 changes: 35 additions & 16 deletions apps/docs/public/img/supabase-architecture--light.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
51 changes: 35 additions & 16 deletions apps/docs/public/img/supabase-architecture.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
162 changes: 162 additions & 0 deletions apps/studio/lib/session-replay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { buildSessionRecordingConfig, type CapturedNetworkRequest } from 'common'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { maskReplayNetworkRequest, maskReplayText, SESSION_REPLAY_CONFIG } from './session-replay'

const elementWith = (attributes: Record<string, string>) => {
const element = document.createElement('span')
Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, value))
return element
}

const networkRequest = (name: string): CapturedNetworkRequest => ({
name,
entryType: 'resource',
startTime: 0,
duration: 0,
})

describe('maskReplayText', () => {
it('masks text by default', () => {
expect(maskReplayText('postgresql://postgres:hunter2@db.abc.supabase.co:5432')).toBe(
'*'.repeat('postgresql://postgres:hunter2@db.abc.supabase.co:5432'.length)
)
})

it('masks text when no element is given', () => {
expect(maskReplayText('secret', undefined)).toBe('******')
})

it('masks based on trimmed length so whitespace is not leaked', () => {
expect(maskReplayText(' abc ')).toBe('***')
})

it('captures text opted in with data-ph-capture', () => {
const element = elementWith({ 'data-ph-capture': 'true' })
expect(maskReplayText('Table editor', element)).toBe('Table editor')
})

it('masks text when data-ph-capture is not exactly "true"', () => {
expect(maskReplayText('secret', elementWith({ 'data-ph-capture': 'false' }))).toBe('******')
expect(maskReplayText('secret', elementWith({ 'data-ph-capture': '' }))).toBe('******')
expect(maskReplayText('secret', elementWith({ 'data-ph-capture': 'TRUE' }))).toBe('******')
})

it('masks text on elements carrying unrelated data attributes', () => {
expect(maskReplayText('secret', elementWith({ 'data-capture': 'true' }))).toBe('******')
})
})

describe('maskReplayNetworkRequest', () => {
it('strips query strings', () => {
expect(
maskReplayNetworkRequest(networkRequest('https://api.supabase.com/v1/x?token=abc')).name
).toBe('https://api.supabase.com/v1/x')
})

it('strips fragments, which carry GoTrue access tokens on auth callbacks', () => {
expect(
maskReplayNetworkRequest(networkRequest('https://supabase.com/dashboard#access_token=abc'))
.name
).toBe('https://supabase.com/dashboard')
})

it('strips from the first separator when both are present', () => {
expect(maskReplayNetworkRequest(networkRequest('https://x.com/a?b=1#c=2')).name).toBe(
'https://x.com/a'
)
expect(maskReplayNetworkRequest(networkRequest('https://x.com/a#c=2?b=1')).name).toBe(
'https://x.com/a'
)
})

it('leaves URLs without a query string or fragment alone', () => {
expect(maskReplayNetworkRequest(networkRequest('https://x.com/project/abc/editor')).name).toBe(
'https://x.com/project/abc/editor'
)
})

it('returns the request rather than dropping it, so timings are still captured', () => {
const request = networkRequest('https://x.com/a?b=1')
expect(maskReplayNetworkRequest(request)).toBe(request)
})
})

describe('SESSION_REPLAY_CONFIG', () => {
it('masks all text and inputs', () => {
expect(SESSION_REPLAY_CONFIG.maskTextSelector).toBe('*')
expect(SESSION_REPLAY_CONFIG.maskAllInputs).toBe(true)
expect(SESSION_REPLAY_CONFIG.maskTextFn).toBe(maskReplayText)
})

it('never records request or response payloads', () => {
expect(SESSION_REPLAY_CONFIG.recordHeaders).toBe(false)
expect(SESSION_REPLAY_CONFIG.recordBody).toBe(false)
})

it('never records canvas, which text masking cannot reach', () => {
expect(SESSION_REPLAY_CONFIG.captureCanvas).toEqual({ recordCanvas: false })
})

it('strips sensitive URL parts via maskReplayNetworkRequest', () => {
expect(SESSION_REPLAY_CONFIG.maskCapturedNetworkRequestFn).toBe(maskReplayNetworkRequest)
})
})

describe('buildSessionRecordingConfig', () => {
it('disables recording when given no policy', () => {
const config = buildSessionRecordingConfig()

expect(config.disable_session_recording).toBe(true)
expect(config).not.toHaveProperty('session_recording')
})

it('disables recording when the policy is undefined', () => {
const config = buildSessionRecordingConfig(undefined)

expect(config.disable_session_recording).toBe(true)
expect(config).not.toHaveProperty('session_recording')
})

it('enables recording and forwards the policy when given one', () => {
const config = buildSessionRecordingConfig(SESSION_REPLAY_CONFIG)

expect(config.disable_session_recording).toBe(false)
expect(config.session_recording).toBe(SESSION_REPLAY_CONFIG)
})

it.each([undefined, SESSION_REPLAY_CONFIG])(
'never records console logs, which masking cannot reach (%#)',
(sessionReplay) => {
expect(buildSessionRecordingConfig(sessionReplay).enable_recording_console_log).toBe(false)
}
)
})

describe('IS_SESSION_REPLAY_ENABLED', () => {
beforeEach(() => {
vi.resetModules()
})

afterEach(() => {
vi.unstubAllEnvs()
})

it('is true only for the exact string "true"', async () => {
vi.stubEnv('NEXT_PUBLIC_POSTHOG_SESSION_REPLAY', 'true')
const { IS_SESSION_REPLAY_ENABLED } = await import('./session-replay')
expect(IS_SESSION_REPLAY_ENABLED).toBe(true)
})

it.each(['false', '', 'TRUE', '1'])('is false for %o', async (value) => {
vi.stubEnv('NEXT_PUBLIC_POSTHOG_SESSION_REPLAY', value)
const { IS_SESSION_REPLAY_ENABLED } = await import('./session-replay')
expect(IS_SESSION_REPLAY_ENABLED).toBe(false)
})

it('is false when unset', async () => {
vi.stubEnv('NEXT_PUBLIC_POSTHOG_SESSION_REPLAY', undefined)
const { IS_SESSION_REPLAY_ENABLED } = await import('./session-replay')
expect(IS_SESSION_REPLAY_ENABLED).toBe(false)
})
})
50 changes: 50 additions & 0 deletions apps/studio/lib/session-replay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { CapturedNetworkRequest, SessionRecordingOptions } from 'common'

/**
* Enables session replay in Studio. Recording also requires "Record user
* sessions" in PostHog, which www and docs share.
*/
export const IS_SESSION_REPLAY_ENABLED = process.env.NEXT_PUBLIC_POSTHOG_SESSION_REPLAY === 'true'

/**
* Setting `data-ph-capture="true"` on an element opts its text in to session
* recording. All text is opted out by default.
*/
const CAPTURE_DATASET_KEY = 'phCapture'

/**
* Returns asterisks for all text except text inside elements marked
* `data-ph-capture="true"`.
*/
export function maskReplayText(text: string, element?: HTMLElement): string {
if (element?.dataset[CAPTURE_DATASET_KEY] === 'true') return text
return '*'.repeat(text.trim().length)
}

/**
* Strips query strings and fragments from recorded URLs, which posthog-js applies
* to page URLs as well as network requests. Auth callbacks carry tokens in the
* fragment.
*/
export function maskReplayNetworkRequest(request: CapturedNetworkRequest): CapturedNetworkRequest {
if (request.name) {
const separatorIndex = request.name.search(/[?#]/)
if (separatorIndex !== -1) {
request.name = request.name.slice(0, separatorIndex)
}
}
return request
}

export const SESSION_REPLAY_CONFIG: SessionRecordingOptions = {
// Match posthog-js defaults, but set here so the PostHog UI can't relax them.
maskAllInputs: true,
maskTextSelector: '*',
maskTextFn: maskReplayText,
// Keeps network capture to URL, status and timing. Overrides the PostHog UI.
recordHeaders: false,
recordBody: false,
// Canvas is captured as images, which text masking can't reach.
captureCanvas: { recordCanvas: false },
maskCapturedNetworkRequestFn: maskReplayNetworkRequest,
}
2 changes: 2 additions & 0 deletions apps/studio/lib/telemetry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useConsentToast } from 'ui-patterns/consent'
import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { API_URL, IS_PLATFORM } from '@/lib/constants'
import { IS_SESSION_REPLAY_ENABLED, SESSION_REPLAY_CONFIG } from '@/lib/session-replay'

export function Telemetry() {
// Although this is "technically" breaking the rules of hooks
Expand Down Expand Up @@ -70,6 +71,7 @@ export function Telemetry() {
hasAcceptedConsent={hasAcceptedConsent}
enabled={IS_PLATFORM}
organizationSlug={organization?.slug}
sessionReplay={IS_SESSION_REPLAY_ENABLED ? SESSION_REPLAY_CONFIG : undefined}
/>
)
}
35 changes: 33 additions & 2 deletions packages/common/posthog-client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import posthog, { PostHogConfig } from 'posthog-js'
import posthog, {
type CapturedNetworkRequest,
type PostHogConfig,
type SessionRecordingOptions,
} from 'posthog-js'

import { safeSessionStorage } from './safe-storage'

export type { CapturedNetworkRequest, SessionRecordingOptions }

// Limit the max number of queued events
// (e.g. if a user navigates around a lot before accepting consent)
const MAX_PENDING_EVENTS = 20
Expand All @@ -23,6 +29,30 @@ interface PostHogClientConfig {
uiHost?: string
}

interface PostHogInitOptions {
hasConsent?: boolean
/**
* Masking policy for session replay. Omit to disable recording, which every app
* sharing this PostHog project does unless it passes a policy of its own.
*/
sessionReplay?: SessionRecordingOptions
}

/**
* Enables session recording when given a masking config, and disables it when
* given nothing.
*/
export function buildSessionRecordingConfig(
sessionReplay?: SessionRecordingOptions
): Partial<PostHogConfig> {
return {
disable_session_recording: !sessionReplay,
// Console output is not in the DOM, so text masking cannot reach it.
enable_recording_console_log: false,
...(sessionReplay && { session_recording: sessionReplay }),
}
}

class PostHogClient {
/** True after posthog.init() is called (prevents double-init) */
private initStarted = false
Expand Down Expand Up @@ -50,7 +80,7 @@ class PostHogClient {
}
}

init(hasConsent: boolean = true) {
init({ hasConsent = true, sessionReplay }: PostHogInitOptions = {}) {
if (this.initStarted || typeof window === 'undefined' || !hasConsent) return

if (!this.config.apiKey) {
Expand All @@ -64,6 +94,7 @@ class PostHogClient {
autocapture: false, // We'll manually track events
capture_pageview: false, // We'll manually track pageviews
capture_pageleave: false, // We'll manually track page leaves
...buildSessionRecordingConfig(sessionReplay),
loaded: (posthog) => {
// Apply pending properties that were set before PostHog
// initialized due to poor connection or user not accepting
Expand Down
Loading
Loading