chore: Use @supabase/config for the code configuration page (#50398)

How to test:
1. Connect a project to GH repo
2. Deploy the `config.toml` once
3. Change some setting in Auth
4. You should see a change in
`/dashboard/project/_/settings/code-configuration`

<img width="1271" height="1186" alt="Screenshot 2026-09-16 at 16 26 39"
src="https://github.com/user-attachments/assets/dfc135a4-e495-489e-88fd-b760383793b4"
/>

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Configuration drift comparisons now use a consistent project
configuration schema.
- Drift details display complete current-environment and `config.toml`
values, grouped by section.
- Matching and unmanaged settings are organized into dedicated sections.
  - Configuration fields link directly to relevant Studio settings.
  - Added a warning that GitHub deployments overwrite local changes.

- **Bug Fixes**
- Configuration updates now refresh project configuration data
automatically.
  - Improved labels and formatting for boolean and redirect URL values.
- Drift errors identify invalid configuration paths and provide
corrective guidance.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Ivan Vasilov authored and GitHub committed 2026-09-21 16:38:37 +02:00
1 parent be9c67a761
commit 8422045b86
30 files changed
+1873 -1899

No files matched your search

@@ -0,0 +1,61 @@
import { toProjectConfigJsonSchema } from '@supabase/config'
import { isPlainObject } from 'lodash'
import { describe, it } from 'vitest'
import { CONFIG_FIELD_REGISTRY, getFieldDefinition } from './ConfigurationDriftPage.constants'
function getConfigFieldPaths(): string[] {
const schema: unknown = toProjectConfigJsonSchema()
const sections = isRecord(schema) ? schema.properties : undefined
if (!isRecord(sections)) return []
const configPaths: string[] = []
function isRecord(value: unknown): value is Record<string, unknown> {
return isPlainObject(value)
}
function walk(node: unknown, path: string[]) {
const properties = isRecord(node) ? node.properties : undefined
if (isRecord(properties) && Object.keys(properties).length > 0) {
for (const [key, childNode] of Object.entries(properties)) walk(childNode, [...path, key])
return
}
configPaths.push(path.join('.'))
}
for (const [section, sectionNode] of Object.entries(sections)) walk(sectionNode, [section])
return configPaths
}
describe('getFieldDefinition', () => {
it('has a definition for every field the default project config can report', () => {
const missingPaths = getConfigFieldPaths().filter(
(configPath) => !getFieldDefinition(configPath)
)
if (missingPaths.length > 0) {
throw new Error(
`CONFIG_FIELD_REGISTRY is missing a definition for:\n${missingPaths.join('\n')}`
)
}
})
// marked as fails because it depends on @supabase/config being fixed. Once it's fixed, this test should pass and
// be kept for future regressions of the package.
it.fails('has no definitions for fields the default project config cannot report', () => {
const validPaths = new Set(getConfigFieldPaths())
const extraPaths = Object.keys(CONFIG_FIELD_REGISTRY).filter(
(configPath) => !validPaths.has(configPath)
)
if (extraPaths.length > 0) {
throw new Error(
`CONFIG_FIELD_REGISTRY has definitions for fields the default project config never reports:\n${extraPaths.join('\n')}`
)
}
})
})
File diff suppressed because it is too large. Load diff
@@ -107,6 +107,9 @@ const CONNECTION: ListGitHubConnectionsResponse['connections'][number] = {
workdir: '',
}
// Field values here are chosen to match @supabase/config's CLI schema defaults (see
// `getDefaultCliConfig`), so the only drift the tests below see is the one they introduce via
// the `auth` param — everything else round-trips as "matched" or "unmanaged".
function createProjectConfigResponse(auth: Record<string, unknown>): V2ProjectConfigResponse {
return {
data: {
@@ -114,27 +117,30 @@ function createProjectConfigResponse(auth: Record<string, unknown>): V2ProjectCo
type: 'project_config',
attributes: {
api: {
db_extra_search_path: 'public',
db_extra_search_path: 'public,extensions',
db_pool: null,
db_pool_acquisition_timeout: 10,
db_schema: 'public',
db_schema: 'public,graphql_public',
max_rows: 1000,
},
auth,
database: {
major_version: 17,
network_restrictions: {
allowed_cidrs: [],
allowed_cidrs: [
{ address: '0.0.0.0/0', type: 'v4' },
{ address: '::/0', type: 'v6' },
],
entitlement: 'disallowed',
status: 'stored',
},
postgres_settings: {},
ssl_enforced: true,
ssl_enforced: false,
},
pooler: {
default_pool_size: 15,
default_pool_size: 20,
ignore_startup_parameters: '',
max_client_conn: 200,
max_client_conn: 100,
pool_mode: 'transaction',
query_wait_timeout: 120,
reserve_pool_size: 0,
@@ -159,10 +165,10 @@ function createProjectConfigResponse(auth: Record<string, unknown>): V2ProjectCo
capabilities: { iceberg_catalog: false, list_v2: true, object_versioning: false },
features: {
iceberg_catalog: { enabled: false, max_catalogs: 0, max_namespaces: 0, max_tables: 0 },
image_transformation: { enabled: true },
image_transformation: { enabled: false },
purge_cache: { enabled: false },
s3_protocol: { enabled: false },
vector_buckets: { enabled: false, max_buckets: 0, max_indexes: 0 },
s3_protocol: { enabled: true },
vector_buckets: { enabled: true, max_buckets: 10, max_indexes: 5 },
},
file_size_limit: 52_428_800,
migration_version: '0',
@@ -270,6 +276,17 @@ describe('ConfigurationDriftPage', () => {
expect(await screen.findByText('All compared settings match')).toBeInTheDocument()
})
test('shows a config.toml decode error with the offending path', async () => {
mockConnectedProject()
mockProjectConfig({ disable_signup: false })
mockGitHubConfig({ api: { max_rows: 'abc' } })
customRender(<ConfigurationDriftPage />, { profileContext: PROFILE_CONTEXT })
expect(await screen.findByText('Could not read config.toml')).toBeInTheDocument()
expect(screen.getByText(/api\.max_rows/)).toBeInTheDocument()
})
test('renders a drift row when the dashboard and config.toml disagree', async () => {
mockConnectedProject()
mockProjectConfig({ disable_signup: false })
@@ -1,15 +1,17 @@
import { useParams } from 'common'
import { ArrowRight, CheckCircle2, FileWarning, Github, Minus, Plus } from 'lucide-react'
import { ArrowRight, CheckCircle2, FileWarning, Github, Plus } from 'lucide-react'
import Link from 'next/link'
import { Button, Card, Skeleton } from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { CollapsibleCardSection } from 'ui-patterns/CollapsibleCardSection'
import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational'
import {
createConfigurationDriftRows,
groupMatchedConfigFields,
groupUnmanagedConfigFields,
type ConfigSectionGroup,
type ConfigurationDriftRow,
type UnmanagedConfigSectionGroup,
} from './ConfigurationDriftPage.utils'
import { AlertError } from '@/components/ui/AlertError'
import { useSelectedGitHubConfigDrift } from '@/hooks/misc/useGitHubConfigDrift'
@@ -98,42 +100,8 @@ export function ConfigurationDriftPageSkeleton() {
)
}
function ConfigurationValuePanel({
label,
values,
kind,
}: {
label: string
values: string[]
kind: 'dashboard' | 'config'
}) {
const Icon = kind === 'dashboard' ? Minus : Plus
return (
<div className="min-w-0 rounded-md border border-border bg-surface-100 p-3">
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-foreground-light">
<Icon
className={kind === 'dashboard' ? 'h-3.5 w-3.5 text-warning' : 'h-3.5 w-3.5 text-brand'}
/>
<span>{label}</span>
</div>
<ul className="space-y-1.5">
{values.map((value) => (
<li key={value} className="break-all font-mono text-xs leading-5 text-foreground">
{value}
</li>
))}
</ul>
</div>
)
}
function ConfigurationDriftItem({ row }: { row: ConfigurationDriftRow }) {
const valueDiff = row.valueDiff
const listDifferenceCount =
valueDiff.kind === 'list'
? Number(valueDiff.onlyInDashboard.length > 0) + Number(valueDiff.onlyInConfig.length > 0)
: 0
return (
<article className="border-t border-border px-4 py-4 first:border-t-0 sm:px-5">
@@ -155,44 +123,22 @@ function ConfigurationDriftItem({ row }: { row: ConfigurationDriftRow }) {
<Link href={row.settingHref}>Open setting</Link>
</Button>
</div>
{valueDiff.kind === 'list' ? (
<div
className={`mt-4 grid gap-3 ${listDifferenceCount > 1 ? 'md:grid-cols-2' : 'grid-cols-1'}`}
>
{valueDiff.onlyInDashboard.length > 0 && (
<ConfigurationValuePanel
label="Only in current environment"
values={valueDiff.onlyInDashboard}
kind="dashboard"
/>
)}
{valueDiff.onlyInConfig.length > 0 && (
<ConfigurationValuePanel
label="Only in config.toml"
values={valueDiff.onlyInConfig}
kind="config"
/>
)}
<div className="mt-4 grid gap-3 md:grid-cols-2">
<div className="min-w-0 rounded-md border border-border bg-surface-100 p-3">
<p className="mb-2 text-xs font-medium text-foreground-light">
Current environment · active
</p>
<pre className="whitespace-pre-wrap break-all font-mono text-xs leading-5 text-foreground">
{valueDiff.dashboardValue}
</pre>
</div>
) : (
<div className="mt-4 grid gap-3 md:grid-cols-2">
<div className="min-w-0 rounded-md border border-border bg-surface-100 p-3">
<p className="mb-2 text-xs font-medium text-foreground-light">
Current environment · active
</p>
<pre className="whitespace-pre-wrap break-all font-mono text-xs leading-5 text-foreground">
{valueDiff.dashboardValue}
</pre>
</div>
<div className="min-w-0 rounded-md border border-border bg-surface-100 p-3">
<p className="mb-2 text-xs font-medium text-foreground-light">config.toml · intended</p>
<pre className="whitespace-pre-wrap break-all font-mono text-xs leading-5 text-foreground">
{valueDiff.configValue}
</pre>
</div>
<div className="min-w-0 rounded-md border border-border bg-surface-100 p-3">
<p className="mb-2 text-xs font-medium text-foreground-light">config.toml · intended</p>
<pre className="whitespace-pre-wrap break-all font-mono text-xs leading-5 text-foreground">
{valueDiff.configValue}
</pre>
</div>
)}
</div>
</article>
)
}
@@ -224,16 +170,21 @@ export function ConfigurationDriftResults({ rows }: { rows: ConfigurationDriftRo
)
}
function UnmanagedConfigSection({ groups }: { groups: UnmanagedConfigSectionGroup[] }) {
function ConfigFieldSection({
title,
description,
groups,
}: {
title: string
description: string
groups: ConfigSectionGroup[]
}) {
const fieldCount = groups.reduce((count, group) => count + group.rows.length, 0)
if (fieldCount === 0) return null
return (
<Card className="px-4 py-4 sm:px-5">
<CollapsibleCardSection
title={`Not tracked in config.toml (${fieldCount})`}
description="These settings aren't declared in config.toml, so they can't drift — shown here for visibility only."
>
<CollapsibleCardSection title={`${title} (${fieldCount})`} description={description}>
<div className="space-y-4">
{groups.map((group) => (
<div key={group.section}>
@@ -266,22 +217,31 @@ function UnmanagedConfigSection({ groups }: { groups: UnmanagedConfigSectionGrou
export function ConfigurationDriftPage() {
const { ref: projectRef = '' } = useParams()
const { summary, unmanagedFields, isReady, isPending, isFetching, isError, error, refetch } =
const { summary, isReady, isPending, isFetching, isError, error, errorSource, refetch } =
useSelectedGitHubConfigDrift()
const driftRows = createConfigurationDriftRows(summary.driftedFields, projectRef)
const comparableSettingCount = summary.managedCount + driftRows.length
const unmanagedGroups = groupUnmanagedConfigFields(unmanagedFields)
const comparableSettingCount = summary.matchedFields.length + driftRows.length
const matchedGroups = groupMatchedConfigFields(summary.matchedFields)
const unmanagedGroups = groupUnmanagedConfigFields(summary.unmanagedFields)
if (isPending) {
return <ConfigurationDriftPageSkeleton />
}
if (isError) {
const isConfigTomlError = errorSource === 'config-toml'
const subject = isConfigTomlError
? 'Could not read config.toml'
: 'Could not check configuration drift'
const description = isConfigTomlError
? 'Fix the listed values on the selected GitHub branch, then refresh.'
: 'Refresh to compare the supported settings with the selected GitHub branch again.'
return (
<AlertError
projectRef={projectRef}
subject="Could not check configuration drift"
description="Refresh to compare the supported settings with the selected GitHub branch again."
subject={subject}
description={description}
error={error}
additionalActions={
<Button size="small" loading={isFetching} onClick={() => refetch()}>
@@ -315,6 +275,19 @@ export function ConfigurationDriftPage() {
return (
<main className="space-y-6">
<Admonition
type="warning"
title="Config.toml will overwrite these settings on next deploy"
description={
<>
This project is connected to GitHub. The next commit redeploys{' '}
<code className="text-code-inline text-xs">config.toml</code> and overwrite changes made
here. To keep these changes, run{' '}
<code className="text-code-inline text-xs">supabase config pull</code> locally.
</>
}
/>
{comparableSettingCount === 0 ? (
<Card className="flex min-h-44 items-center justify-center px-6 text-center">
<div className="max-w-lg">
@@ -332,8 +305,8 @@ export function ConfigurationDriftPage() {
<CheckCircle2 className="mx-auto mb-3 h-6 w-6 text-brand" />
<h2 className="text-sm font-medium">All compared settings match</h2>
<p className="mt-1 text-sm text-foreground-light">
The current environment matches all {summary.managedCount} comparable settings in this
branch.
The current environment matches all {summary.matchedFields.length} comparable settings
in this branch.
</p>
</div>
</Card>
@@ -341,7 +314,17 @@ export function ConfigurationDriftPage() {
<ConfigurationDriftResults rows={driftRows} />
)}
<UnmanagedConfigSection groups={unmanagedGroups} />
<ConfigFieldSection
title="Matching config.toml"
description="These settings already match between the current environment and config.toml."
groups={matchedGroups}
/>
<ConfigFieldSection
title="Not tracked in config.toml"
description="These settings aren't declared in config.toml, so they can't drift — shown here for visibility only."
groups={unmanagedGroups}
/>
</main>
)
}
@@ -2,9 +2,14 @@ import { describe, expect, it } from 'vitest'
import {
createConfigurationDriftRows,
groupMatchedConfigFields,
groupUnmanagedConfigFields,
} from './ConfigurationDriftPage.utils'
import type { GitHubConfigDriftField, UnmanagedConfigField } from './github-config-drift'
import type {
GitHubConfigDriftField,
MatchedConfigField,
UnmanagedConfigField,
} from './github-config-drift'
const PROJECT_REF = 'abcdefgh'
@@ -28,6 +33,15 @@ function unmanagedField(overrides: Partial<UnmanagedConfigField>): UnmanagedConf
}
}
function matchedField(overrides: Partial<MatchedConfigField>): MatchedConfigField {
return {
section: 'api',
configPath: 'api.max_rows',
value: 1000,
...overrides,
}
}
describe('createConfigurationDriftRows', () => {
it('resolves the setting href against the project ref and marks the row as drifted', () => {
const [row] = createConfigurationDriftRows([driftField({})], PROJECT_REF)
@@ -51,7 +65,7 @@ describe('createConfigurationDriftRows', () => {
PROJECT_REF
)
expect(row.settingLabel).toBe('Google · Client ID')
expect(row.settingLabel).toBe('Google client ID')
})
it('falls back to a title-cased last path segment for an unrecognized config path', () => {
@@ -63,25 +77,6 @@ describe('createConfigurationDriftRows', () => {
expect(row.settingLabel).toBe('Unknown Setting')
})
it('builds a scalar diff, formatting booleans and empty values for display', () => {
const [row] = createConfigurationDriftRows(
[
driftField({
configPath: 'auth.enable_signup',
dashboardValue: true,
githubValue: false,
}),
],
PROJECT_REF
)
expect(row.valueDiff).toEqual({
kind: 'scalar',
dashboardValue: 'Enabled',
configValue: 'Disabled',
})
})
it('formats a missing scalar value as "Not set"', () => {
const [row] = createConfigurationDriftRows(
[driftField({ configPath: 'auth.site_url', dashboardValue: '', githubValue: undefined })],
@@ -108,26 +103,11 @@ describe('createConfigurationDriftRows', () => {
)
expect(row.valueDiff).toEqual({
kind: 'list',
onlyInDashboard: ['https://b.com'],
onlyInConfig: ['https://c.com'],
configValue: ' https://a.com \nhttps://c.com',
dashboardValue: 'https://a.com\nhttps://b.com',
kind: 'scalar',
})
})
it('reports no diff entries when redirect URL lists are equal after normalization', () => {
const [row] = createConfigurationDriftRows(
[
driftField({
configPath: 'auth.additional_redirect_urls',
dashboardValue: ['https://a.com'],
githubValue: ['https://a.com', 'https://a.com'],
}),
],
PROJECT_REF
)
expect(row.valueDiff).toEqual({ kind: 'list', onlyInDashboard: [], onlyInConfig: [] })
})
})
describe('groupUnmanagedConfigFields', () => {
@@ -147,7 +127,7 @@ describe('groupUnmanagedConfigFields', () => {
sectionLabel: 'API',
rows: [
{ configPath: 'api.max_rows', label: 'Max rows', value: '1000' },
{ configPath: 'api.enabled', label: 'API enabled', value: 'Enabled' },
{ configPath: 'api.enabled', label: 'API enabled', value: 'true' },
],
})
})
@@ -174,10 +154,42 @@ describe('groupUnmanagedConfigFields', () => {
unmanagedField({
section: 'auth',
configPath: 'auth.additional_redirect_urls',
dashboardValue: ['https://b.com', ' https://a.com ', 'https://a.com'],
dashboardValue: ['https://b.com', 'https://a.com', 'https://a.com'],
}),
])
expect(groups[0].rows[0].value).toBe('https://a.com\nhttps://b.com')
expect(groups[0].rows[0].value).toBe('https://b.com\nhttps://a.com\nhttps://a.com')
})
})
describe('groupMatchedConfigFields', () => {
it('returns an empty array when there are no matched fields', () => {
expect(groupMatchedConfigFields([])).toEqual([])
})
it('groups fields from the same section into a single group', () => {
const groups = groupMatchedConfigFields([
matchedField({ configPath: 'api.max_rows', value: 1000 }),
matchedField({ configPath: 'api.enabled', value: true }),
])
expect(groups).toHaveLength(1)
expect(groups[0]).toEqual({
section: 'api',
sectionLabel: 'API',
rows: [
{ configPath: 'api.max_rows', label: 'Max rows', value: '1000' },
{ configPath: 'api.enabled', label: 'API enabled', value: 'true' },
],
})
})
it('orders groups by CONFIG_SECTIONS order, not by input order', () => {
const groups = groupMatchedConfigFields([
matchedField({ section: 'storage', configPath: 'storage.enabled', value: true }),
matchedField({ section: 'api', configPath: 'api.enabled', value: true }),
])
expect(groups.map((group) => group.section)).toEqual(['api', 'storage'])
})
})
@@ -1,156 +1,22 @@
import {
type GitHubConfigDriftField,
type UnmanagedConfigField,
} from '@/components/interfaces/ConfigDrift/github-config-drift'
import { startCase } from 'lodash'
import {
CONFIG_SECTIONS,
normalizeRedirectUrls,
getFieldDefinition,
type ConfigSection,
} from '@/components/interfaces/ConfigDrift/github-config-field-registry'
/** Keyed by config.toml path — the single identifier a drifted or unmanaged field carries. */
const FIELD_LABELS: Record<string, string> = {
'auth.enable_signup': 'New user signups',
'auth.enable_anonymous_sign_ins': 'Anonymous sign-ins',
'auth.enable_manual_linking': 'Manual account linking',
'auth.site_url': 'Site URL',
'auth.additional_redirect_urls': 'Redirect URLs',
'auth.email.enable_signup': 'Email signups',
'auth.sms.enable_signup': 'Phone signups',
'auth.email.enable_confirmations': 'Email confirmations',
'auth.email.double_confirm_changes': 'Secure email change',
'auth.email.otp_length': 'Email OTP length',
'auth.email.otp_expiry': 'Email OTP expiry',
'auth.minimum_password_length': 'Minimum password length',
'auth.password_requirements': 'Password requirements',
'auth.sms.provider': 'SMS provider',
'auth.sms.enable_confirmations': 'SMS confirmations',
'auth.sms.otp_expiry': 'SMS OTP expiry',
'auth.sms.otp_length': 'SMS OTP length',
'auth.sms.template': 'SMS template',
'api.max_rows': 'Max rows',
'storage.file_size_limit': 'File size limit',
'api.enabled': 'API enabled',
'api.port': 'API port',
'api.schemas': 'Exposed schemas',
'api.extra_search_path': 'Extra search path',
'api.tls.enabled': 'Enforce TLS',
'db.port': 'Database port',
'db.shadow_port': 'Shadow database port',
'db.major_version': 'Postgres major version',
'db.pooler.enabled': 'Connection pooler enabled',
'db.pooler.port': 'Pooler port',
'db.pooler.pool_mode': 'Pool mode',
'db.pooler.default_pool_size': 'Default pool size',
'db.pooler.max_client_conn': 'Max client connections',
'db.migrations.enabled': 'Migrations enabled',
'db.migrations.schema_paths': 'Migration schema paths',
'db.seed.enabled': 'Seed enabled',
'db.seed.sql_paths': 'Seed file paths',
'db.network_restrictions.enabled': 'Network restrictions enabled',
'db.network_restrictions.allowed_cidrs': 'Allowed CIDRs (IPv4)',
'db.network_restrictions.allowed_cidrs_v6': 'Allowed CIDRs (IPv6)',
'realtime.enabled': 'Realtime enabled',
'studio.enabled': 'Studio enabled',
'studio.port': 'Studio port',
'studio.api_url': 'Studio API URL',
'inbucket.enabled': 'Inbucket enabled',
'inbucket.port': 'Inbucket port',
'storage.enabled': 'Storage enabled',
'storage.s3_protocol.enabled': 'S3 protocol enabled',
'storage.analytics.enabled': 'Storage analytics enabled',
'storage.analytics.max_namespaces': 'Max analytics namespaces',
'storage.analytics.max_tables': 'Max analytics tables',
'storage.analytics.max_catalogs': 'Max analytics catalogs',
'storage.vector.enabled': 'Storage vector enabled',
'storage.vector.max_buckets': 'Max vector buckets',
'storage.vector.max_indexes': 'Max vector indexes',
'auth.enabled': 'Auth enabled',
'auth.jwt_expiry': 'JWT expiry',
'auth.enable_refresh_token_rotation': 'Refresh token rotation',
'auth.refresh_token_reuse_interval': 'Refresh token reuse interval',
'auth.rate_limit.email_sent': 'Email rate limit',
'auth.rate_limit.sms_sent': 'SMS rate limit',
'auth.rate_limit.anonymous_users': 'Anonymous sign-in rate limit',
'auth.rate_limit.token_refresh': 'Token refresh rate limit',
'auth.rate_limit.sign_in_sign_ups': 'Sign-in/sign-up rate limit',
'auth.rate_limit.token_verifications': 'Token verification rate limit',
'auth.rate_limit.web3': 'Web3 rate limit',
'auth.email.secure_password_change': 'Secure email change',
'auth.email.max_frequency': 'Email send frequency limit',
'auth.sms.max_frequency': 'SMS send frequency limit',
'auth.sms.twilio.enabled': 'Twilio enabled',
'auth.mfa.max_enrolled_factors': 'Max enrolled MFA factors',
'auth.mfa.totp.enroll_enabled': 'TOTP enrollment',
'auth.mfa.totp.verify_enabled': 'TOTP verification',
'auth.mfa.phone.enroll_enabled': 'Phone MFA enrollment',
'auth.mfa.phone.verify_enabled': 'Phone MFA verification',
'auth.mfa.phone.otp_length': 'Phone MFA OTP length',
'auth.mfa.phone.template': 'Phone MFA template',
'auth.mfa.phone.max_frequency': 'Phone MFA send frequency limit',
'auth.web3.solana.enabled': 'Solana Web3 enabled',
'auth.oauth_server.enabled': 'OAuth server enabled',
'auth.oauth_server.authorization_url_path': 'OAuth authorization URL path',
'auth.oauth_server.allow_dynamic_registration': 'Allow dynamic client registration',
'edge_runtime.enabled': 'Edge runtime enabled',
'edge_runtime.policy': 'Edge runtime policy',
'edge_runtime.inspector_port': 'Edge runtime inspector port',
'edge_runtime.deno_version': 'Deno version',
'analytics.enabled': 'Analytics enabled',
'analytics.port': 'Analytics port',
'analytics.backend': 'Analytics backend',
'experimental.orioledb_version': 'OrioleDB version',
'experimental.s3_host': 'S3 host',
'experimental.s3_region': 'S3 region',
'experimental.s3_access_key': 'S3 access key',
'experimental.s3_secret_key': 'S3 secret key',
}
/** config.toml path of the redirect URL list, which renders as an added/removed diff, not a scalar. */
const REDIRECT_URLS_CONFIG_PATH = 'auth.additional_redirect_urls'
const CONFIG_KEY_LABELS: Record<string, string> = {
client_id: 'Client ID',
email_optional: 'Email optional',
enabled: 'Enabled',
redirect_uri: 'Redirect URI',
skip_nonce_check: 'Skip nonce check',
url: 'URL',
}
const PROVIDER_LABELS: Record<string, string> = {
apple: 'Apple',
azure: 'Azure',
bitbucket: 'Bitbucket',
discord: 'Discord',
facebook: 'Facebook',
figma: 'Figma',
github: 'GitHub',
gitlab: 'GitLab',
google: 'Google',
kakao: 'Kakao',
keycloak: 'Keycloak',
linkedin_oidc: 'LinkedIn (OIDC)',
notion: 'Notion',
slack_oidc: 'Slack (OIDC)',
spotify: 'Spotify',
twitch: 'Twitch',
workos: 'WorkOS',
zoom: 'Zoom',
}
} from '@/components/interfaces/ConfigDrift/ConfigurationDriftPage.constants'
import {
type GitHubConfigDriftField,
type MatchedConfigField,
type UnmanagedConfigField,
} from '@/components/interfaces/ConfigDrift/github-config-drift'
const CONFIG_SECTION_LABELS: Record<ConfigSection, string> = {
api: 'API',
auth: 'Auth',
db: 'Database',
storage: 'Storage',
realtime: 'Realtime',
studio: 'Studio',
inbucket: 'Inbucket',
functions: 'Edge Functions',
edge_runtime: 'Edge Runtime',
analytics: 'Analytics',
remotes: 'Remotes',
compute: 'Compute',
experimental: 'Experimental',
}
@@ -163,17 +29,11 @@ interface ConfigurationIssueRowMeta {
export type ConfigurationDriftRow = Omit<GitHubConfigDriftField, 'settingHref'> &
ConfigurationIssueRowMeta & { status: 'drifted' }
type ConfigurationDriftValueDiff =
| {
kind: 'list'
onlyInDashboard: string[]
onlyInConfig: string[]
}
| {
kind: 'scalar'
dashboardValue: string
configValue: string
}
type ConfigurationDriftValueDiff = {
kind: 'scalar'
dashboardValue: string
configValue: string
}
interface UnmanagedConfigRow {
configPath: string
@@ -181,7 +41,7 @@ interface UnmanagedConfigRow {
value: string
}
export interface UnmanagedConfigSectionGroup {
export interface ConfigSectionGroup {
section: ConfigSection
sectionLabel: string
rows: UnmanagedConfigRow[]
@@ -202,7 +62,20 @@ export function createConfigurationDriftRows(
export function groupUnmanagedConfigFields(
fields: readonly UnmanagedConfigField[]
): UnmanagedConfigSectionGroup[] {
): ConfigSectionGroup[] {
return groupConfigFieldsBySection(fields, (field) => field.dashboardValue)
}
export function groupMatchedConfigFields(
fields: readonly MatchedConfigField[]
): ConfigSectionGroup[] {
return groupConfigFieldsBySection(fields, (field) => field.value)
}
function groupConfigFieldsBySection<T extends { section: ConfigSection; configPath: string }>(
fields: readonly T[],
getValue: (field: T) => unknown
): ConfigSectionGroup[] {
const bySection = new Map<ConfigSection, UnmanagedConfigRow[]>()
for (const field of fields) {
@@ -210,7 +83,7 @@ export function groupUnmanagedConfigFields(
rows.push({
configPath: field.configPath,
label: getConfigFieldLabel(field.configPath),
value: formatConfigFieldValue(field.configPath, field.dashboardValue),
value: formatConfigFieldValue(getValue(field)),
})
bySection.set(field.section, rows)
}
@@ -222,9 +95,8 @@ export function groupUnmanagedConfigFields(
}))
}
function formatConfigFieldValue(configPath: string, value: unknown): string {
const normalizedValue =
configPath === REDIRECT_URLS_CONFIG_PATH ? normalizeRedirectUrls(value) : value
function formatConfigFieldValue(value: unknown): string {
const normalizedValue = value
if (normalizedValue === undefined || normalizedValue === null || normalizedValue === '') {
return 'Not set'
@@ -241,57 +113,25 @@ function formatConfigFieldValue(configPath: string, value: unknown): string {
function createConfigurationDriftValueDiff(
field: GitHubConfigDriftField
): ConfigurationDriftValueDiff {
if (field.configPath === REDIRECT_URLS_CONFIG_PATH) {
const dashboardUrls = normalizeRedirectUrls(field.dashboardValue)
const configUrls = normalizeRedirectUrls(field.githubValue)
const dashboardUrlSet = new Set(dashboardUrls)
const configUrlSet = new Set(configUrls)
return {
kind: 'list',
onlyInDashboard: dashboardUrls.filter((url) => !configUrlSet.has(url)),
onlyInConfig: configUrls.filter((url) => !dashboardUrlSet.has(url)),
}
}
return {
kind: 'scalar',
dashboardValue: formatConfigFieldValue(field.configPath, field.dashboardValue),
configValue: formatConfigFieldValue(field.configPath, field.githubValue),
dashboardValue: formatConfigFieldValue(field.dashboardValue),
configValue: formatConfigFieldValue(field.githubValue),
}
}
/**
* Prefers a hand-written label, then the `auth.external.<provider>.<key>` shape, and otherwise
* title-cases the last path segment — never the whole path, which would leave the dots in.
* Prefers the registry's hand-written label, and otherwise title-cases the last path segment —
* never the whole path, which would leave the dots in.
*/
function getConfigFieldLabel(configPath: string): string {
const staticLabel = FIELD_LABELS[configPath]
if (staticLabel) return staticLabel
const [, section, provider, configKey] = configPath.split('.')
if (section === 'external' && provider && configKey) {
return `${formatProviderLabel(provider)} · ${CONFIG_KEY_LABELS[configKey] ?? titleCase(configKey)}`
}
return titleCase(configPath.split('.').at(-1) ?? configPath)
const label = getFieldDefinition(configPath)?.label
return label ?? startCase(configPath.split('.').at(-1) ?? configPath)
}
function formatScalarValue(value: unknown): string {
if (typeof value === 'string') return value || 'Not set'
if (typeof value === 'boolean') return value ? 'Enabled' : 'Disabled'
if (typeof value === 'boolean') return value ? 'true' : 'false'
if (typeof value === 'number') return String(value)
return JSON.stringify(value, null, 2) ?? 'Not set'
}
function formatProviderLabel(provider: string): string {
return PROVIDER_LABELS[provider] ?? titleCase(provider)
}
function titleCase(value: string): string {
return value
.split('_')
.filter(Boolean)
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1).toLowerCase()}`)
.join(' ')
}
@@ -1,177 +0,0 @@
import { describe, expect, it } from 'vitest'
import { convertProjectConfigToGitHubConfig } from './github-config-convert'
describe('convertProjectConfigToGitHubConfig', () => {
it('returns an empty object when no dashboard config is provided', () => {
expect(convertProjectConfigToGitHubConfig()).toEqual({})
})
it('maps registered fields onto their nested config.toml path', () => {
const result = convertProjectConfigToGitHubConfig({
auth: {
site_url: 'https://example.com',
disable_signup: false,
mailer_otp_length: 6,
},
api: {
max_rows: 1000,
},
})
expect(result).toEqual({
auth: {
site_url: 'https://example.com',
enable_signup: true,
email: { otp_length: 6 },
},
api: { max_rows: 1000 },
})
})
it('inverts booleans that config.toml expresses the opposite way', () => {
const result = convertProjectConfigToGitHubConfig({
auth: { disable_signup: true },
})
expect(result).toEqual({ auth: { enable_signup: false } })
})
it('omits fields it has no conversion mapping for, secret-named or not', () => {
const result = convertProjectConfigToGitHubConfig({
auth: {
sms_twilio_auth_token: 'super-secret',
some_unregistered_field: 'value',
},
})
expect(result).toEqual({})
})
it('skips sections that are absent from the dashboard config', () => {
const result = convertProjectConfigToGitHubConfig({ database: { some_field: 'x' } })
expect(result).toEqual({})
})
describe('auth.additional_redirect_urls', () => {
it('splits the comma-separated list, then dedupes, trims and sorts it', () => {
const result = convertProjectConfigToGitHubConfig({
auth: { uri_allow_list: 'https://b.com, https://a.com ,https://b.com' },
})
expect(result).toEqual({
auth: { additional_redirect_urls: ['https://a.com', 'https://b.com'] },
})
})
it('maps an empty list to an empty array, not to an absent field', () => {
const result = convertProjectConfigToGitHubConfig({ auth: { uri_allow_list: '' } })
expect(result).toEqual({ auth: { additional_redirect_urls: [] } })
})
it('omits the field when the dashboard has no list at all', () => {
const result = convertProjectConfigToGitHubConfig({ auth: { site_url: 'https://a.com' } })
expect(result).toEqual({ auth: { site_url: 'https://a.com' } })
})
})
it('leaves order-sensitive comma lists in their original order', () => {
// Only the redirect allow-list is a set — `extra_search_path` order is meaningful to Postgres.
const result = convertProjectConfigToGitHubConfig({
api: { db_extra_search_path: 'public, extensions, auth' },
})
expect(result).toEqual({ api: { extra_search_path: ['public', 'extensions', 'auth'] } })
})
describe('db.network_restrictions', () => {
it('derives enabled from status and splits allowed CIDRs by type', () => {
const result = convertProjectConfigToGitHubConfig({
database: {
network_restrictions: {
status: 'applied',
allowed_cidrs: [
{ type: 'v4', address: '10.0.0.0/24' },
{ type: 'v6', address: '::1/128' },
],
},
},
})
expect(result).toEqual({
db: {
network_restrictions: {
enabled: true,
allowed_cidrs: ['10.0.0.0/24'],
allowed_cidrs_v6: ['::1/128'],
},
},
})
})
})
describe('db.pooler', () => {
it('maps the top-level dashboard pooler section under db.pooler', () => {
const result = convertProjectConfigToGitHubConfig({
pooler: { pool_mode: 'transaction', default_pool_size: 20, max_client_conn: 100 },
})
expect(result).toEqual({
db: { pooler: { pool_mode: 'transaction', default_pool_size: 20, max_client_conn: 100 } },
})
})
})
describe('storage', () => {
it('maps file_size_limit and renames feature flags onto their config.toml fields', () => {
const result = convertProjectConfigToGitHubConfig({
storage: {
file_size_limit: 52428800,
features: {
s3_protocol: { enabled: true },
iceberg_catalog: {
enabled: true,
max_namespaces: 10,
max_tables: 100,
max_catalogs: 5,
},
vector_buckets: { enabled: false, max_buckets: 2, max_indexes: 4 },
},
},
})
expect(result).toEqual({
storage: {
file_size_limit: 52428800,
s3_protocol: { enabled: true },
analytics: { enabled: true, max_namespaces: 10, max_tables: 100, max_catalogs: 5 },
vector: { enabled: false, max_buckets: 2, max_indexes: 4 },
},
})
})
})
describe('auth.password_requirements', () => {
it('maps NO_REQUIRED_CHARS and null to an empty string', () => {
expect(
convertProjectConfigToGitHubConfig({
auth: { password_required_characters: 'NO_REQUIRED_CHARS' },
})
).toEqual({ auth: { password_requirements: '' } })
expect(
convertProjectConfigToGitHubConfig({ auth: { password_required_characters: null } })
).toEqual({ auth: { password_requirements: '' } })
})
it('passes through any other value unchanged', () => {
const result = convertProjectConfigToGitHubConfig({
auth: { password_required_characters: 'abcdefgABCDEFG01234' },
})
expect(result).toEqual({ auth: { password_requirements: 'abcdefgABCDEFG01234' } })
})
})
})
@@ -1,350 +0,0 @@
import {
EXTERNAL_AUTH_PROVIDERS,
EXTERNAL_AUTH_PROVIDERS_WITH_URL,
normalizeRedirectUrls,
type ConfigDriftDashboardConfig,
} from './github-config-field-registry'
import { type GitHubConfigToml } from './github-config.types'
/**
* Reshapes a project's dashboard config (sections keyed by section name, fields in their
* dashboard-native naming, e.g. from `GET /v1/projects/:ref/config`) into the nested, dotted-path
* shape of a parsed config.toml.
*
* Not every dashboard field has a config.toml counterpart — untracked fields are simply never read
* here. See the comments throughout for renames/inversions/unit conversions and for fields that
* were deliberately left out because config.toml has no equivalent.
*/
export function convertProjectConfigToGitHubConfig(
dashboardConfig?: ConfigDriftDashboardConfig
): GitHubConfigToml {
if (!dashboardConfig) return {}
const database = dashboardConfig.database
const config: GitHubConfigToml = {
db: {
network_restrictions: convertNetworkRestrictions(database?.network_restrictions),
pooler: convertPooler(dashboardConfig.pooler),
},
api: convertApi(dashboardConfig.api),
auth: convertAuth(dashboardConfig.auth),
storage: convertStorage(dashboardConfig.storage),
// realtime: gitHubConfigTomlSchema only models `realtime.enabled`, and the dashboard's realtime
// section (private_only, max_concurrent_users, max_events_per_second, ...) never provides one —
// there is nothing to map.
}
// database.ssl_enforced: dashboard-only security toggle, not managed via config.toml
// database.postgres_settings: user-set Postgres GUC overrides, applied directly to the database
// rather than declared in config.toml
return pruneUndefined(config) ?? {}
}
/**
* Every convertX helper always returns every field it knows about, most of them `undefined` when
* the source dashboard config didn't have that value. Recursively drop `undefined` leaves and the
* empty objects left behind, so callers only see the fields that actually had a value.
*/
function pruneUndefined<T>(value: T): T | undefined {
if (value === undefined || Array.isArray(value) || typeof value !== 'object' || value === null) {
return value === undefined ? undefined : value
}
const result: Record<string, unknown> = {}
for (const [key, nestedValue] of Object.entries(value)) {
const pruned = pruneUndefined(nestedValue)
if (pruned !== undefined) result[key] = pruned
}
return Object.keys(result).length > 0 ? (result as T) : undefined
}
function convertNetworkRestrictions(
value: unknown
): NonNullable<NonNullable<GitHubConfigToml['db']>['network_restrictions']> | undefined {
const restrictions = asRecord(value)
if (!restrictions) return undefined
const cidrs = Array.isArray(restrictions.allowed_cidrs) ? restrictions.allowed_cidrs : undefined
const addressesOfType = (type: string) =>
cidrs
?.map((cidr) => asRecord(cidr))
.filter((cidr): cidr is Record<string, unknown> => cidr?.type === type)
.map((cidr) => asString(cidr.address))
.filter((address): address is string => address !== undefined)
return {
// dashboard has no single boolean — derived from `status === 'applied'`. `entitlement` (plan
// gating) and `status` (rollout state) have no config.toml equivalent.
enabled: restrictions.status === undefined ? undefined : restrictions.status === 'applied',
allowed_cidrs: addressesOfType('v4'),
allowed_cidrs_v6: addressesOfType('v6'),
}
}
function convertPooler(
value: unknown
): NonNullable<NonNullable<GitHubConfigToml['db']>['pooler']> | undefined {
// dashboard's "pooler" is a top-level section; config.toml nests it under `db.pooler`
const pooler = asRecord(value)
if (!pooler) return undefined
return {
pool_mode: asString(pooler.pool_mode),
default_pool_size: asNumber(pooler.default_pool_size),
max_client_conn: asNumber(pooler.max_client_conn),
// ignore_startup_parameters, server_idle_timeout, server_lifetime, query_wait_timeout,
// reserve_pool_size: Supavisor-only settings, no config.toml field.
}
}
function convertApi(value: unknown): GitHubConfigToml['api'] {
const api = asRecord(value)
if (!api) return undefined
return {
max_rows: asNumber(api.max_rows),
schemas: splitCommaList(api.db_schema),
extra_search_path: splitCommaList(api.db_extra_search_path),
// db_pool, db_pool_acquisition_timeout: no config.toml field.
}
}
function convertAuth(value: unknown): GitHubConfigToml['auth'] {
const auth = asRecord(value)
if (!auth) return undefined
return {
site_url: asString(auth.site_url),
// uri_allow_list is a comma-separated string on the dashboard; config.toml wants a string[]
additional_redirect_urls: convertRedirectUrls(auth.uri_allow_list),
jwt_expiry: asNumber(auth.jwt_exp),
// disable_signup -> enable_signup (inverted boolean)
enable_signup: invertBoolean(auth.disable_signup),
enable_manual_linking: asBoolean(auth.security_manual_linking_enabled),
// refresh_token_rotation_enabled -> enable_refresh_token_rotation (renamed)
enable_refresh_token_rotation: asBoolean(auth.refresh_token_rotation_enabled),
// security_refresh_token_reuse_interval -> refresh_token_reuse_interval (renamed)
refresh_token_reuse_interval: asNumber(auth.security_refresh_token_reuse_interval),
minimum_password_length: asNumber(auth.password_min_length),
password_requirements: normalizePasswordRequirements(auth.password_required_characters),
enable_anonymous_sign_ins: asBoolean(auth.external_anonymous_users_enabled),
rate_limit: convertAuthRateLimit(auth),
email: convertAuthEmail(auth),
sms: convertAuthSms(auth),
mfa: convertAuthMfa(auth),
external: convertAuthExternalProviders(auth),
// external_web3_solana_enabled -> web3.solana.enabled; external_web3_ethereum_enabled has no
// config.toml field (schema only models solana)
web3: { solana: { enabled: asBoolean(auth.external_web3_solana_enabled) } },
oauth_server: {
enabled: asBoolean(auth.oauth_server_enabled),
allow_dynamic_registration: asBoolean(auth.oauth_server_allow_dynamic_registration),
// oauth_server_authorization_path -> authorization_url_path (renamed)
authorization_url_path: asString(auth.oauth_server_authorization_path),
},
// api_max_request_duration, db_max_pool_size(_unit), security_update_password_require_*,
// audit_log_disable_postgres, sessions_*, hook_*_enabled/uri (secrets are always excluded
// regardless), nimbus_oauth_client_id, mailer_allow_unverified_email_sign_ins,
// mailer_notifications_*, password_hibp_enabled, saml_*, security_sb_forwarded_for_enabled,
// security_captcha_*, sms_test_otp(_valid_until), index_worker_ensure_user_search_indexes_exist,
// custom_oauth_enabled/max_providers, mailer_subjects_custom_contents,
// mailer_templates_custom_contents: none of these have a gitHubConfigTomlSchema field.
}
}
function normalizePasswordRequirements(value: unknown): string | undefined {
if (value === null || value === 'NO_REQUIRED_CHARS') return ''
return asString(value)
}
function convertAuthRateLimit(
auth: Record<string, unknown>
): NonNullable<GitHubConfigToml['auth']>['rate_limit'] {
return {
email_sent: asNumber(auth.rate_limit_email_sent),
sms_sent: asNumber(auth.rate_limit_sms_sent),
anonymous_users: asNumber(auth.rate_limit_anonymous_users),
token_refresh: asNumber(auth.rate_limit_token_refresh),
web3: asNumber(auth.rate_limit_web3),
// Confirmed against the Auth service source (supabase/auth#2090): the dashboard's
// RATE_LIMIT_VERIFY backs "rate limit for token verifications" and RATE_LIMIT_OTP backs
// "rate limit for sign ups and sign ins", despite the naming mismatch.
token_verifications: asNumber(auth.rate_limit_verify),
sign_in_sign_ups: asNumber(auth.rate_limit_otp),
}
}
function convertAuthEmail(
auth: Record<string, unknown>
): NonNullable<GitHubConfigToml['auth']>['email'] {
return {
enable_signup: asBoolean(auth.external_email_enabled),
// mailer_autoconfirm -> enable_confirmations (inverted boolean)
enable_confirmations: invertBoolean(auth.mailer_autoconfirm),
double_confirm_changes: asBoolean(auth.mailer_secure_email_change_enabled),
otp_length: asNumber(auth.mailer_otp_length),
otp_expiry: asNumber(auth.mailer_otp_exp),
// smtp_max_frequency is seconds on the dashboard vs. a duration string ("1m") in config.toml —
// needs a number -> duration-string conversion, not done here. smtp_pass is a secret, excluded.
// template subjects/content: dashboard stores literal subject/HTML, config.toml stores
// `{ subject, content_path }` where content_path is a file path — not convertible without
// writing the HTML to disk.
}
}
function convertAuthSms(
auth: Record<string, unknown>
): NonNullable<GitHubConfigToml['auth']>['sms'] {
return {
provider: asString(auth.sms_provider),
// sms_autoconfirm -> enable_confirmations (inverted boolean)
enable_confirmations: invertBoolean(auth.sms_autoconfirm),
otp_expiry: asNumber(auth.sms_otp_exp),
otp_length: asNumber(auth.sms_otp_length),
template: asString(auth.sms_template),
// sms_max_frequency is seconds on the dashboard vs. a duration string in config.toml — needs
// conversion, not done here.
twilio: {
account_sid: asStringOrNull(auth.sms_twilio_account_sid),
message_service_sid: asStringOrNull(auth.sms_twilio_message_service_sid),
content_sid: asStringOrNull(auth.sms_twilio_content_sid),
// auth_token is a secret, excluded
},
twilio_verify: {
account_sid: asStringOrNull(auth.sms_twilio_verify_account_sid),
message_service_sid: asStringOrNull(auth.sms_twilio_verify_message_service_sid),
// auth_token is a secret, excluded
},
messagebird: { originator: asStringOrNull(auth.sms_messagebird_originator) },
textlocal: { sender: asStringOrNull(auth.sms_textlocal_sender) },
vonage: { from: asStringOrNull(auth.sms_vonage_from) },
}
}
function convertAuthMfa(
auth: Record<string, unknown>
): NonNullable<GitHubConfigToml['auth']>['mfa'] {
return {
max_enrolled_factors: asNumber(auth.mfa_max_enrolled_factors),
totp: {
enroll_enabled: asBoolean(auth.mfa_totp_enroll_enabled),
verify_enabled: asBoolean(auth.mfa_totp_verify_enabled),
},
phone: {
enroll_enabled: asBoolean(auth.mfa_phone_enroll_enabled),
verify_enabled: asBoolean(auth.mfa_phone_verify_enabled),
otp_length: asNumber(auth.mfa_phone_otp_length),
template: asString(auth.mfa_phone_template),
// mfa_phone_max_frequency is seconds on the dashboard vs. a duration string in config.toml —
// needs conversion, not done here.
},
// mfa_allow_low_aal, mfa_web_authn_enroll_enabled, mfa_web_authn_verify_enabled,
// passkey_enabled, webauthn_rp_*: no config.toml field.
}
}
function convertAuthExternalProviders(
auth: Record<string, unknown>
): NonNullable<GitHubConfigToml['auth']>['external'] {
const providers: Record<string, Record<string, unknown>> = {}
for (const provider of EXTERNAL_AUTH_PROVIDERS) {
providers[provider] = {
enabled: asBoolean(auth[`external_${provider}_enabled`]),
client_id: asStringOrNull(auth[`external_${provider}_client_id`]),
email_optional: asBoolean(auth[`external_${provider}_email_optional`]),
skip_nonce_check: asBoolean(auth[`external_${provider}_skip_nonce_check`]),
...(EXTERNAL_AUTH_PROVIDERS_WITH_URL.has(provider)
? { url: asStringOrNull(auth[`external_${provider}_url`]) }
: {}),
}
}
// external_apple_additional_client_ids, external_google_additional_client_ids,
// external_google_skip_nonce_check: dashboard-only extensions with no config.toml field.
return providers
}
function convertStorage(value: unknown): GitHubConfigToml['storage'] {
const storage = asRecord(value)
if (!storage) return undefined
const features = asRecord(storage.features)
const s3Protocol = asRecord(features?.s3_protocol)
// features.iceberg_catalog -> analytics (renamed)
const icebergCatalog = asRecord(features?.iceberg_catalog)
// features.vector_buckets -> vector (renamed)
const vectorBuckets = asRecord(features?.vector_buckets)
return {
file_size_limit: asNumber(storage.file_size_limit),
s3_protocol: { enabled: asBoolean(s3Protocol?.enabled) },
analytics: {
enabled: asBoolean(icebergCatalog?.enabled),
max_namespaces: asNumber(icebergCatalog?.max_namespaces),
max_tables: asNumber(icebergCatalog?.max_tables),
max_catalogs: asNumber(icebergCatalog?.max_catalogs),
},
vector: {
enabled: asBoolean(vectorBuckets?.enabled),
max_buckets: asNumber(vectorBuckets?.max_buckets),
max_indexes: asNumber(vectorBuckets?.max_indexes),
},
// features.image_transformation.enabled, features.purge_cache.enabled: no config.toml field.
// capabilities.list_v2, capabilities.iceberg_catalog: read-only capability flags, not settings.
// upstream_target, migration_version: internal infra bookkeeping.
}
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined
}
function asString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined
}
function asStringOrNull(value: unknown): string | null | undefined {
return value === null ? null : asString(value)
}
function asNumber(value: unknown): number | undefined {
return typeof value === 'number' ? value : undefined
}
function asBoolean(value: unknown): boolean | undefined {
return typeof value === 'boolean' ? value : undefined
}
function invertBoolean(value: unknown): boolean | undefined {
const bool = asBoolean(value)
return bool === undefined ? undefined : !bool
}
/**
* Unlike the other comma-separated lists (`api.schemas`, `api.extra_search_path`) whose order is
* meaningful, a redirect allow-list is a set. Dedupe and sort it here so a list that differs from
* config.toml only in ordering isn't reported as drift — `getConfigFieldState` compares with
* `JSON.stringify`, which is order-sensitive.
*/
function convertRedirectUrls(value: unknown): string[] | undefined {
const urls = splitCommaList(value)
if (urls === undefined) return undefined
return normalizeRedirectUrls(urls)
}
function splitCommaList(value: unknown): string[] | undefined {
const raw = asString(value)
if (raw === undefined) return undefined
if (raw.trim() === '') return []
return raw
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
}
@@ -1,24 +1,48 @@
import { describe, expect, it } from 'vitest'
import { getConfigDriftSummary } from './github-config-drift'
import {
formatGitHubConfigDecodeMessage,
getConfigDriftSummary,
type ConfigDriftResult,
type GitHubConfigDriftSummary,
} from './github-config-drift'
/**
* Asserts `result` is the `success` branch of `ConfigDriftResult` and returns its summary, so the
* existing drift-comparison tests below can keep asserting on the summary shape directly.
*/
function summaryOf(result: ConfigDriftResult): GitHubConfigDriftSummary {
if (result.status !== 'success') {
throw new Error(`Expected a successful decode, got ${result.status}`)
}
return result.summary
}
describe('getConfigDriftSummary', () => {
it('counts a matching field as managed', () => {
const summary = getConfigDriftSummary({
dashboardConfig: { api: { max_rows: 1000 } },
githubConfig: { api: { max_rows: 1000 } },
})
const summary = summaryOf(
getConfigDriftSummary({
dashboardConfig: { api: { max_rows: 1000 } },
githubConfig: { api: { max_rows: 1000 } },
})
)
expect(summary).toEqual({ managedCount: 1, driftedFields: [], unmanagedFields: [] })
expect(summary).toEqual({
driftedFields: [],
matchedFields: [{ section: 'api', configPath: 'api.max_rows', value: 1000 }],
unmanagedFields: [],
})
})
it('reports a differing field as drifted, keeping raw (non-normalized) display values', () => {
const summary = getConfigDriftSummary({
dashboardConfig: { auth: { enable_signup: false } },
githubConfig: { auth: { enable_signup: true } },
})
const summary = summaryOf(
getConfigDriftSummary({
dashboardConfig: { auth: { enable_signup: false } },
githubConfig: { auth: { enable_signup: true } },
})
)
expect(summary.managedCount).toBe(0)
expect(summary.matchedFields).toEqual([])
expect(summary.driftedFields).toEqual([
{
section: 'auth',
@@ -30,89 +54,93 @@ describe('getConfigDriftSummary', () => {
])
})
it('excludes a secret field even when dashboard and config.toml values match', () => {
const summary = getConfigDriftSummary({
dashboardConfig: { auth: { jwt_secret: 'shh' } },
githubConfig: { auth: { jwt_secret: 'shh' } },
})
expect(summary).toEqual({ managedCount: 0, driftedFields: [], unmanagedFields: [] })
})
it('reports a field absent from config.toml as unmanaged', () => {
const summary = getConfigDriftSummary({
dashboardConfig: { auth: { site_url: 'https://example.com' } },
githubConfig: { auth: {} },
})
expect(summary).toEqual({
managedCount: 0,
driftedFields: [],
unmanagedFields: [
{ section: 'auth', configPath: 'auth.site_url', dashboardValue: 'https://example.com' },
],
})
})
it('falls back to the hosted default when config.toml is code-owned and silent on the field', () => {
const atDefault = getConfigDriftSummary({
dashboardConfig: { auth: { site_url: 'http://localhost:3000' } },
githubConfig: { auth: {}, config_source: 'code' },
})
expect(atDefault).toEqual({
managedCount: 0,
driftedFields: [],
unmanagedFields: [
{ section: 'auth', configPath: 'auth.site_url', dashboardValue: 'http://localhost:3000' },
],
})
const drifted = getConfigDriftSummary({
dashboardConfig: { auth: { site_url: 'https://example.com' } },
githubConfig: { auth: {}, config_source: 'code' },
})
it('reports a field missing from config.toml as drifted against the hosted value', () => {
const drifted = summaryOf(
getConfigDriftSummary({
dashboardConfig: { auth: { site_url: 'https://example.com' } },
githubConfig: { auth: {} },
})
)
expect(drifted.driftedFields).toHaveLength(1)
expect(drifted.driftedFields[0].githubValue).toBe('http://localhost:3000')
// site_url defaults to http://127.0.0.1:3000
expect(drifted.driftedFields[0].githubValue).toEqual('http://127.0.0.1:3000')
})
describe('auth.additional_redirect_urls', () => {
it('counts an identical list as managed', () => {
const summary = getConfigDriftSummary({
dashboardConfig: { auth: { additional_redirect_urls: ['https://a.com', 'https://b.com'] } },
githubConfig: { auth: { additional_redirect_urls: ['https://a.com', 'https://b.com'] } },
})
const summary = summaryOf(
getConfigDriftSummary({
dashboardConfig: {
auth: { additional_redirect_urls: ['https://a.com', 'https://b.com'] },
},
githubConfig: { auth: { additional_redirect_urls: ['https://a.com', 'https://b.com'] } },
})
)
expect(summary).toEqual({ managedCount: 1, driftedFields: [], unmanagedFields: [] })
expect(summary).toEqual({
driftedFields: [],
matchedFields: [
{
section: 'auth',
configPath: 'auth.additional_redirect_urls',
value: ['https://a.com', 'https://b.com'],
},
],
unmanagedFields: [],
})
})
it('counts a list that differs only in order as managed', () => {
const summary = getConfigDriftSummary({
// `convertProjectConfigToGitHubConfig` sorts the dashboard list and `normalizeGithubValue`
// sorts the config.toml one, so ordering can never register as drift.
dashboardConfig: { auth: { additional_redirect_urls: ['https://a.com', 'https://b.com'] } },
githubConfig: { auth: { additional_redirect_urls: ['https://b.com', 'https://a.com'] } },
})
const summary = summaryOf(
getConfigDriftSummary({
// `fromApiProjectConfig` sorts the dashboard list and `normalizeGithubValue` sorts the
// config.toml one, so ordering can never register as drift.
dashboardConfig: {
auth: { additional_redirect_urls: ['https://a.com', 'https://b.com'] },
},
githubConfig: { auth: { additional_redirect_urls: ['https://b.com', 'https://a.com'] } },
})
)
expect(summary).toEqual({ managedCount: 1, driftedFields: [], unmanagedFields: [] })
expect(summary).toEqual({
driftedFields: [],
matchedFields: [
{
section: 'auth',
configPath: 'auth.additional_redirect_urls',
value: ['https://a.com', 'https://b.com'],
},
],
unmanagedFields: [],
})
})
it('ignores duplicate and untrimmed entries in config.toml', () => {
const summary = getConfigDriftSummary({
dashboardConfig: { auth: { additional_redirect_urls: ['https://a.com'] } },
githubConfig: {
auth: { additional_redirect_urls: [' https://a.com ', 'https://a.com', ''] },
},
})
// `auth.additional_redirect_urls` is a registry "set"-equality field whose document-side
// canonicalization re-joins-and-splits the array (mirroring a push/pull round trip), which
// trims each entry — so padding and exact-duplicate entries never register as drift.
const summary = summaryOf(
getConfigDriftSummary({
dashboardConfig: { auth: { additional_redirect_urls: ['https://a.com'] } },
githubConfig: {
auth: { additional_redirect_urls: [' https://a.com ', 'https://a.com'] },
},
})
)
expect(summary.managedCount).toBe(1)
expect(summary.matchedFields).toHaveLength(1)
expect(summary.driftedFields).toEqual([])
})
it('reports a list the dashboard adds to as drifted, keeping the raw dashboard list', () => {
const summary = getConfigDriftSummary({
dashboardConfig: { auth: { additional_redirect_urls: ['https://a.com', 'https://b.com'] } },
githubConfig: { auth: { additional_redirect_urls: ['https://a.com'] } },
})
const summary = summaryOf(
getConfigDriftSummary({
dashboardConfig: {
auth: { additional_redirect_urls: ['https://a.com', 'https://b.com'] },
},
githubConfig: { auth: { additional_redirect_urls: ['https://a.com'] } },
})
)
expect(summary.driftedFields).toEqual([
{
@@ -125,20 +153,107 @@ describe('getConfigDriftSummary', () => {
])
})
it('compares against the empty hosted default when code-owned config.toml is silent', () => {
const atDefault = getConfigDriftSummary({
dashboardConfig: { auth: { additional_redirect_urls: [] } },
githubConfig: { auth: {}, config_source: 'code' },
})
expect(atDefault.managedCount).toBe(0)
expect(atDefault.driftedFields).toEqual([])
const drifted = getConfigDriftSummary({
dashboardConfig: { auth: { additional_redirect_urls: ['https://a.com'] } },
githubConfig: { auth: {}, config_source: 'code' },
})
it('reports a list missing from config.toml as drifted', () => {
const drifted = summaryOf(
getConfigDriftSummary({
dashboardConfig: { auth: { additional_redirect_urls: ['https://a.com'] } },
githubConfig: { auth: {} },
})
)
expect(drifted.driftedFields).toHaveLength(1)
expect(drifted.driftedFields[0].githubValue).toEqual([])
// additional_redirect_urls defaults to ['https://127.0.0.1:3000']
expect(drifted.driftedFields[0].githubValue).toEqual(['https://127.0.0.1:3000'])
expect(drifted.driftedFields[0].dashboardValue).toEqual(['https://a.com'])
})
})
describe('invalid config.toml', () => {
it('reports a wrong scalar type with its dotted path', () => {
const result = getConfigDriftSummary({
dashboardConfig: { api: { max_rows: 1000 } },
githubConfig: { api: { max_rows: 'abc' } },
})
expect(result).toEqual({
status: 'invalid-config',
issues: [{ path: 'api.max_rows', message: 'Expected number' }],
})
})
it('reports an explicit null the same way as a wrong scalar type', () => {
const result = getConfigDriftSummary({
dashboardConfig: { api: { max_rows: 1000 } },
githubConfig: { api: { max_rows: null } },
})
expect(result).toEqual({
status: 'invalid-config',
issues: [{ path: 'api.max_rows', message: 'Expected number' }],
})
})
it('reports a nested path', () => {
const result = getConfigDriftSummary({
dashboardConfig: { auth: { enable_signup: true } },
githubConfig: { auth: { external: { github: { enabled: 'yes' } } } },
})
expect(result).toEqual({
status: 'invalid-config',
issues: [{ path: 'auth.external.github.enabled', message: 'Expected boolean' }],
})
})
it('reports every bad field in one pass', () => {
const result = getConfigDriftSummary({
dashboardConfig: { api: { max_rows: 1000 } },
githubConfig: { api: { max_rows: 'abc', port: 'x' } },
})
expect(result.status).toEqual('invalid-config')
expect(result).toMatchObject({
issues: expect.arrayContaining([
{ path: 'api.max_rows', message: 'Expected number' },
{ path: 'api.port', message: 'Expected number' },
]),
})
if (result.status === 'invalid-config') {
expect(result.issues).toHaveLength(2)
}
})
it('still decodes successfully when the document has unknown sections or keys', () => {
const summary = summaryOf(
getConfigDriftSummary({
dashboardConfig: { api: { max_rows: 1000 } },
githubConfig: { api: { max_rows: 1000, made_up_key: true }, made_up_section: {} },
})
)
expect(summary.matchedFields).toEqual([
{ section: 'api', configPath: 'api.max_rows', value: 1000 },
])
})
})
})
describe('formatGitHubConfigDecodeMessage', () => {
it('formats a single issue as one sentence', () => {
const message = formatGitHubConfigDecodeMessage([
{ path: 'api.max_rows', message: 'Expected number' },
])
expect(message).toEqual('config.toml has an invalid value at api.max_rows: expected number.')
})
it('formats multiple issues as a comma-separated list', () => {
const message = formatGitHubConfigDecodeMessage([
{ path: 'api.max_rows', message: 'Expected number' },
{ path: 'api.port', message: 'Expected number' },
])
expect(message).toEqual(
'config.toml has invalid values: api.max_rows (expected number), api.port (expected number).'
)
})
})
@@ -1,23 +1,21 @@
import {
CliConfigSchema,
diffProjectConfig,
fromApiProjectConfig,
type CliConfig,
type ConfigChange,
type ProjectConfig,
} from '@supabase/config'
import { Result, Schema, SchemaIssue } from 'effect'
import { isPlainObject, lowerFirst } from 'lodash'
import {
CONFIG_SECTIONS,
getConfigValue,
getFieldDefinition,
getSectionFieldEntries,
isSecretConfigField,
toProjectHomepageHref,
type ConfigSection,
} from './github-config-field-registry'
import { gitHubConfigTomlSchema, type GitHubConfigToml } from './github-config.types'
// This file should be temporary and will be removed once we publish a "@supabase/config" package.
type GitHubConfigFieldStatus = 'unmanaged' | 'managed' | 'drifted'
interface GitHubConfigFieldState {
status: GitHubConfigFieldStatus
configPath?: string
settingHref?: (projectRef: string) => string
githubValue?: unknown
}
} from './ConfigurationDriftPage.constants'
export interface GitHubConfigDriftField {
section: ConfigSection
@@ -33,109 +31,185 @@ export interface UnmanagedConfigField {
dashboardValue: unknown
}
interface GitHubConfigDriftSummary {
managedCount: number
export interface MatchedConfigField {
section: ConfigSection
configPath: string
value: unknown
}
export interface GitHubConfigDriftSummary {
driftedFields: GitHubConfigDriftField[]
matchedFields: MatchedConfigField[]
unmanagedFields: UnmanagedConfigField[]
}
function getConfigFieldState({
configPath,
dashboardConfig,
githubConfig,
}: {
configPath: string
dashboardConfig: GitHubConfigToml
githubConfig?: GitHubConfigToml
}): GitHubConfigFieldState {
const definition = getFieldDefinition(configPath)
if (!definition || !githubConfig || isSecretConfigField(definition.configPath)) {
return { status: 'unmanaged' }
}
const normalizedDashboardValue = getConfigValue(dashboardConfig, definition.configPath)
let githubValue = getConfigValue(githubConfig, definition.configPath)
if (githubValue === undefined) {
const isCodeOwned = getConfigValue(githubConfig, 'config_source') === 'code'
if (!isCodeOwned || definition.hostedDefault === undefined) return { status: 'unmanaged' }
const normalizedDefaultValue =
definition.normalizeGithubValue?.(definition.hostedDefault) ?? definition.hostedDefault
if (valuesMatch(normalizedDashboardValue, normalizedDefaultValue)) {
return { status: 'unmanaged' }
}
githubValue = definition.hostedDefault
}
const normalizedGithubValue = definition.normalizeGithubValue?.(githubValue) ?? githubValue
return {
status: valuesMatch(normalizedDashboardValue, normalizedGithubValue) ? 'managed' : 'drifted',
configPath: definition.configPath,
settingHref: definition.settingHref,
githubValue,
}
export interface GitHubConfigDecodeIssue {
path: string // e.g. 'api.max_rows'
message: string
}
export type GitHubConfigDecodeResult =
| { status: 'success'; config: CliConfig; document: Record<string, unknown> }
| { status: 'invalid'; issues: GitHubConfigDecodeIssue[] }
const EMPTY_SUMMARY: GitHubConfigDriftSummary = {
driftedFields: [],
matchedFields: [],
unmanagedFields: [],
}
/**
* Converts a v2 project-config API response's `attributes` into the hosted-section shape both
* sides of a drift comparison are normalized to. Returns `undefined` when `attributes` isn't
* loaded yet; throws if the API returned something @supabase/config can't map, so callers can
* surface it as an error rather than silently reporting no drift.
*/
export function fromDashboardProjectConfig(attributes: unknown): ProjectConfig | undefined {
if (attributes === undefined) return undefined
return fromApiProjectConfig(attributes)
}
/**
* Decodes a parsed config.toml document into the `{ config, document }` pair `diffProjectConfig`
* takes as its local operand. Keeping the raw `document` alongside the decoded `config` is what
* unlocks raw-presence masking (distinguishing "the file wrote this value" from "the file inherited
* a schema default") — see `DiffProjectConfigOptions.local`'s own docstring. Returns `undefined`
* when `document` isn't loaded yet; returns `{ status: 'invalid', issues }` when it fails to decode
* against the schema, so callers can surface the offending path(s) rather than silently reporting
* no drift.
*/
export function decodeGithubConfigDocument(
document: unknown
): GitHubConfigDecodeResult | undefined {
if (!isRecord(document)) return undefined
const result = Schema.decodeUnknownResult(CliConfigSchema, { errors: 'all' })(document)
if (Result.isFailure(result)) {
const formatter = SchemaIssue.makeFormatterStandardSchemaV1()
const issues = formatter(result.failure.issue).issues.map((issue) => ({
path: (issue.path ?? []).map(String).join('.'),
message: issue.message,
}))
return { status: 'invalid', issues }
}
return { status: 'success', config: result.success, document }
}
function isRecord(value: unknown): value is Record<string, unknown> {
return isPlainObject(value)
}
/**
* Whether `configPath` (dotted, e.g. `api.max_rows`) is declared anywhere in the raw config.toml
* document — walking the parsed document itself rather than the decoded config, since decoding
* fills in schema defaults for every field the file never mentioned. This is the only reliable way
* to tell "config.toml set this" apart from "config.toml is silent and this happens to equal the
* default" — a distinction `diffProjectConfig`'s change classification collapses when the two
* sides' values coincide.
*/
function isPathDeclaredInDocument(
document: Record<string, unknown> | undefined,
configPath: string
): boolean {
let current: unknown = document
for (const segment of configPath.split('.')) {
if (!isRecord(current) || !(segment in current)) return false
current = current[segment]
}
return true
}
export type ConfigDriftResult =
| { status: 'success'; summary: GitHubConfigDriftSummary }
| { status: 'invalid-config'; issues: GitHubConfigDecodeIssue[] }
/**
* Returns `{ status: 'invalid-config', issues }` if `githubConfig` fails to decode against the
* schema (see `decodeGithubConfigDocument`), so callers can surface the offending path(s) instead
* of silently reporting no drift.
*/
export function getConfigDriftSummary({
dashboardConfig,
githubConfig,
}: {
dashboardConfig?: GitHubConfigToml
githubConfig?: GitHubConfigToml
}): GitHubConfigDriftSummary {
dashboardConfig?: ProjectConfig
githubConfig?: Record<string, unknown>
}): ConfigDriftResult {
if (!dashboardConfig || !githubConfig) {
return { managedCount: 0, driftedFields: [], unmanagedFields: [] }
return { status: 'success', summary: EMPTY_SUMMARY }
}
const githubConfigResult = gitHubConfigTomlSchema.safeParse(githubConfig)
const dashboardConfigResult = gitHubConfigTomlSchema.safeParse(dashboardConfig)
if (!githubConfigResult.success || !dashboardConfigResult.success) {
return { managedCount: 0, driftedFields: [], unmanagedFields: [] }
const decodedGithubConfig = decodeGithubConfigDocument(githubConfig)
if (!decodedGithubConfig) {
return { status: 'success', summary: EMPTY_SUMMARY }
}
if (decodedGithubConfig.status === 'invalid') {
return { status: 'invalid-config', issues: decodedGithubConfig.issues }
}
const cleanedGithubConfig = githubConfigResult.data
const cleanedDashboardConfig = dashboardConfigResult.data
let managedCount = 0
const changeSet = diffProjectConfig({
local: { config: decodedGithubConfig.config, document: decodedGithubConfig.document },
remote: dashboardConfig,
})
const changesByPath = new Map<string, ConfigChange>(
changeSet.changes.map((change) => [change.path.join('.'), change])
)
const maskedPaths = new Set(changeSet.masked.map((path) => path.join('.')))
const unmanagedByPushPaths = new Set(changeSet.unmanaged.map((path) => path.join('.')))
const driftedFields: GitHubConfigDriftField[] = []
const matchedFields: MatchedConfigField[] = []
const unmanagedFields: UnmanagedConfigField[] = []
for (const section of CONFIG_SECTIONS) {
const sectionConfig = cleanedDashboardConfig[section]
const sectionConfig = dashboardConfig[section]
if (!sectionConfig) continue
for (const { configPath, rawValue } of getSectionFieldEntries(section, sectionConfig)) {
if (isSecretConfigField(configPath)) continue
if (maskedPaths.has(configPath)) continue
const state = getConfigFieldState({
configPath,
dashboardConfig: cleanedDashboardConfig,
githubConfig: cleanedGithubConfig,
})
const change = changesByPath.get(configPath)
if (change) {
const definition = getFieldDefinition(configPath)
if (state.status === 'managed') {
managedCount += 1
driftedFields.push({
section,
configPath,
settingHref: definition?.settingHref ?? toProjectHomepageHref,
dashboardValue: change.remote,
githubValue: change.local,
})
continue
}
if (state.status === 'unmanaged' || !state.configPath || !state.settingHref) {
const isTrackedInConfigToml = isPathDeclaredInDocument(
decodedGithubConfig.document,
configPath
)
if (unmanagedByPushPaths.has(configPath) || !isTrackedInConfigToml) {
unmanagedFields.push({ section, configPath, dashboardValue: rawValue })
continue
}
driftedFields.push({
section,
configPath: state.configPath,
settingHref: state.settingHref,
dashboardValue: rawValue,
githubValue: state.githubValue,
})
matchedFields.push({ section, configPath, value: rawValue })
}
}
return { managedCount, driftedFields, unmanagedFields }
return { status: 'success', summary: { driftedFields, matchedFields, unmanagedFields } }
}
function valuesMatch(left: unknown, right: unknown): boolean {
return JSON.stringify(left) === JSON.stringify(right)
/**
* Turns `GitHubConfigDecodeIssue[]` into a single-sentence, user-facing message for `AlertError`,
* which renders `error.message` in a `<p>` — Effect's own multi-line default reads badly there.
*/
export function formatGitHubConfigDecodeMessage(issues: GitHubConfigDecodeIssue[]): string {
if (issues.length === 1) {
const [issue] = issues
return `config.toml has an invalid value at ${issue.path}: ${lowerFirst(issue.message)}.`
}
const details = issues.map((issue) => `${issue.path} (${lowerFirst(issue.message)})`).join(', ')
return `config.toml has invalid values: ${details}.`
}
@@ -1,555 +0,0 @@
import { type GitHubConfigToml } from './github-config.types'
import { StorageSizeUnits } from '@/components/interfaces/Storage/StorageSettings/StorageSettings.constants'
import { convertToBytes } from '@/components/interfaces/Storage/StorageSettings/StorageSettings.utils'
// This file should be temporary and will be removed once we publish a "@supabase/config" package.
// Every top-level config.toml section, excluding `project_id` which is a scalar, not a section.
export const CONFIG_SECTIONS = [
'api',
'auth',
'db',
'storage',
'realtime',
'studio',
'inbucket',
'functions',
'edge_runtime',
'analytics',
'remotes',
'experimental',
] as const satisfies readonly Exclude<keyof GitHubConfigToml, 'project_id'>[]
export type ConfigSection = (typeof CONFIG_SECTIONS)[number]
// The v2 project config API's own top-level section names — distinct from `CONFIG_SECTIONS`,
// which mirrors config.toml's naming (e.g. `pooler` here nests under config.toml's `db.pooler`).
const DASHBOARD_CONFIG_SECTIONS = [
'api',
'auth',
'database',
'pooler',
'realtime',
'storage',
] as const
type DashboardConfigSection = (typeof DASHBOARD_CONFIG_SECTIONS)[number]
export type ConfigDriftDashboardConfig = Partial<
Record<DashboardConfigSection, Record<string, unknown>>
>
interface ConfigFieldDefinition {
settingHref: (projectRef: string) => string
/** Value to compare against when the field is absent from a code-owned config.toml. */
hostedDefault?: unknown
normalizeGithubValue?: (value: unknown) => unknown
}
export type ResolvedConfigFieldDefinition = ConfigFieldDefinition & { configPath: string }
// external_<provider>_enabled/client_id/email_optional/skip_nonce_check is the dashboard's flat
// naming for every OAuth provider; a handful of self-hosted providers also expose `_url`.
// external_<provider>_secret is always a secret and is never read here.
export const EXTERNAL_AUTH_PROVIDERS = [
'apple',
'azure',
'bitbucket',
'discord',
'facebook',
'figma',
'github',
'gitlab',
'google',
'kakao',
'keycloak',
'linkedin_oidc',
'notion',
'slack',
'slack_oidc',
'spotify',
'twitch',
'twitter',
'x',
'workos',
'zoom',
] as const
export const EXTERNAL_AUTH_PROVIDERS_WITH_URL = new Set(['azure', 'gitlab', 'keycloak', 'workos'])
const toAuthUrlConfigHref = (projectRef: string) => `/project/${projectRef}/auth/url-configuration`
const toAuthProvidersHref = (projectRef: string) => `/project/${projectRef}/auth/providers`
const toApiSettingsHref = (projectRef: string) => `/project/${projectRef}/settings/api`
const toStorageSettingsHref = (projectRef: string) =>
`/project/${projectRef}/storage/files/settings`
const toProjectHref = (projectRef: string) => `/project/${projectRef}`
/**
* Every trackable field across every section, keyed by its config.toml dotted path — the same shape
* `getConfigValue`/`setConfigValue` address, and the single identifier a field is known by once the
* dashboard config has been run through `convertProjectConfigToGitHubConfig`.
*/
const CONFIG_FIELD_REGISTRY: Record<string, ConfigFieldDefinition> = {
'auth.enable_signup': {
settingHref: toAuthProvidersHref,
},
'auth.enable_anonymous_sign_ins': {
settingHref: toAuthProvidersHref,
},
'auth.enable_manual_linking': {
settingHref: toAuthProvidersHref,
},
'auth.site_url': {
settingHref: toAuthUrlConfigHref,
hostedDefault: 'http://localhost:3000',
},
'auth.additional_redirect_urls': {
settingHref: toAuthUrlConfigHref,
hostedDefault: [],
normalizeGithubValue: normalizeRedirectUrls,
},
'auth.email.enable_signup': {
settingHref: toAuthProvidersHref,
},
'auth.sms.enable_signup': {
settingHref: toAuthProvidersHref,
},
'auth.email.enable_confirmations': {
settingHref: toAuthProvidersHref,
},
'auth.email.double_confirm_changes': {
settingHref: toAuthProvidersHref,
},
'auth.email.otp_length': {
settingHref: toAuthProvidersHref,
},
'auth.email.otp_expiry': {
settingHref: toAuthProvidersHref,
},
'auth.minimum_password_length': {
settingHref: toAuthProvidersHref,
},
'auth.password_requirements': {
settingHref: toAuthProvidersHref,
},
'auth.sms.provider': {
settingHref: toAuthProvidersHref,
},
'auth.sms.enable_confirmations': {
settingHref: toAuthProvidersHref,
},
'auth.sms.otp_expiry': {
settingHref: toAuthProvidersHref,
},
'auth.sms.otp_length': {
settingHref: toAuthProvidersHref,
},
'auth.sms.template': {
settingHref: toAuthProvidersHref,
},
'auth.sms.twilio.account_sid': {
settingHref: toAuthProvidersHref,
},
'auth.sms.twilio.message_service_sid': {
settingHref: toAuthProvidersHref,
},
'auth.sms.twilio.content_sid': {
settingHref: toAuthProvidersHref,
},
'auth.sms.twilio_verify.account_sid': {
settingHref: toAuthProvidersHref,
},
'auth.sms.twilio_verify.message_service_sid': {
settingHref: toAuthProvidersHref,
},
'auth.sms.messagebird.originator': {
settingHref: toAuthProvidersHref,
},
'auth.sms.textlocal.sender': {
settingHref: toAuthProvidersHref,
},
'auth.sms.vonage.from': {
settingHref: toAuthProvidersHref,
},
'api.max_rows': {
settingHref: toApiSettingsHref,
},
'storage.file_size_limit': {
settingHref: toStorageSettingsHref,
normalizeGithubValue: parseFileSizeToBytes,
},
'api.enabled': {
settingHref: toProjectHref,
},
'api.port': {
settingHref: toProjectHref,
},
'api.schemas': {
settingHref: toProjectHref,
},
'api.extra_search_path': {
settingHref: toProjectHref,
},
'api.tls.enabled': {
settingHref: toProjectHref,
},
'db.port': {
settingHref: toProjectHref,
},
'db.shadow_port': {
settingHref: toProjectHref,
},
'db.major_version': {
settingHref: toProjectHref,
},
'db.pooler.enabled': {
settingHref: toProjectHref,
},
'db.pooler.port': {
settingHref: toProjectHref,
},
'db.pooler.pool_mode': {
settingHref: toProjectHref,
},
'db.pooler.default_pool_size': {
settingHref: toProjectHref,
},
'db.pooler.max_client_conn': {
settingHref: toProjectHref,
},
'db.migrations.enabled': {
settingHref: toProjectHref,
},
'db.migrations.schema_paths': {
settingHref: toProjectHref,
},
'db.seed.enabled': {
settingHref: toProjectHref,
},
'db.seed.sql_paths': {
settingHref: toProjectHref,
},
'db.network_restrictions.enabled': {
settingHref: toProjectHref,
},
'db.network_restrictions.allowed_cidrs': {
settingHref: toProjectHref,
},
'db.network_restrictions.allowed_cidrs_v6': {
settingHref: toProjectHref,
},
'realtime.enabled': {
settingHref: toProjectHref,
},
'studio.enabled': {
settingHref: toProjectHref,
},
'studio.port': {
settingHref: toProjectHref,
},
'studio.api_url': {
settingHref: toProjectHref,
},
'inbucket.enabled': {
settingHref: toProjectHref,
},
'inbucket.port': {
settingHref: toProjectHref,
},
'storage.enabled': {
settingHref: toProjectHref,
},
'storage.s3_protocol.enabled': {
settingHref: toProjectHref,
},
'storage.analytics.enabled': {
settingHref: toProjectHref,
},
'storage.analytics.max_namespaces': {
settingHref: toProjectHref,
},
'storage.analytics.max_tables': {
settingHref: toProjectHref,
},
'storage.analytics.max_catalogs': {
settingHref: toProjectHref,
},
'storage.vector.enabled': {
settingHref: toProjectHref,
},
'storage.vector.max_buckets': {
settingHref: toProjectHref,
},
'storage.vector.max_indexes': {
settingHref: toProjectHref,
},
'auth.enabled': {
settingHref: toProjectHref,
},
'auth.jwt_expiry': {
settingHref: toProjectHref,
},
'auth.enable_refresh_token_rotation': {
settingHref: toProjectHref,
},
'auth.refresh_token_reuse_interval': {
settingHref: toProjectHref,
},
'auth.rate_limit.email_sent': {
settingHref: toProjectHref,
},
'auth.rate_limit.sms_sent': {
settingHref: toProjectHref,
},
'auth.rate_limit.anonymous_users': {
settingHref: toProjectHref,
},
'auth.rate_limit.token_refresh': {
settingHref: toProjectHref,
},
'auth.rate_limit.sign_in_sign_ups': {
settingHref: toProjectHref,
},
'auth.rate_limit.token_verifications': {
settingHref: toProjectHref,
},
'auth.rate_limit.web3': {
settingHref: toProjectHref,
},
'auth.email.secure_password_change': {
settingHref: toProjectHref,
},
'auth.email.max_frequency': {
settingHref: toProjectHref,
},
'auth.sms.max_frequency': {
settingHref: toProjectHref,
},
'auth.sms.twilio.enabled': {
settingHref: toProjectHref,
},
'auth.mfa.max_enrolled_factors': {
settingHref: toProjectHref,
},
'auth.mfa.totp.enroll_enabled': {
settingHref: toProjectHref,
},
'auth.mfa.totp.verify_enabled': {
settingHref: toProjectHref,
},
'auth.mfa.phone.enroll_enabled': {
settingHref: toProjectHref,
},
'auth.mfa.phone.verify_enabled': {
settingHref: toProjectHref,
},
'auth.mfa.phone.otp_length': {
settingHref: toProjectHref,
},
'auth.mfa.phone.template': {
settingHref: toProjectHref,
},
'auth.mfa.phone.max_frequency': {
settingHref: toProjectHref,
},
'auth.web3.solana.enabled': {
settingHref: toProjectHref,
},
'auth.oauth_server.enabled': {
settingHref: toProjectHref,
},
'auth.oauth_server.authorization_url_path': {
settingHref: toProjectHref,
},
'auth.oauth_server.allow_dynamic_registration': {
settingHref: toProjectHref,
},
'edge_runtime.enabled': {
settingHref: toProjectHref,
},
'edge_runtime.policy': {
settingHref: toProjectHref,
},
'edge_runtime.inspector_port': {
settingHref: toProjectHref,
},
'edge_runtime.deno_version': {
settingHref: toProjectHref,
},
'analytics.enabled': {
settingHref: toProjectHref,
},
'analytics.port': {
settingHref: toProjectHref,
},
'analytics.backend': {
settingHref: toProjectHref,
},
'experimental.orioledb_version': {
settingHref: toProjectHref,
},
'experimental.s3_host': {
settingHref: toProjectHref,
},
'experimental.s3_region': {
settingHref: toProjectHref,
},
'experimental.s3_access_key': {
settingHref: toProjectHref,
},
'experimental.s3_secret_key': {
settingHref: toProjectHref,
},
}
for (const provider of EXTERNAL_AUTH_PROVIDERS) {
for (const field of ['enabled', 'client_id', 'email_optional', 'skip_nonce_check']) {
CONFIG_FIELD_REGISTRY[`auth.external.${provider}.${field}`] = {
settingHref: toAuthProvidersHref,
}
}
if (EXTERNAL_AUTH_PROVIDERS_WITH_URL.has(provider)) {
CONFIG_FIELD_REGISTRY[`auth.external.${provider}.url`] = { settingHref: toAuthProvidersHref }
}
}
/**
* config.toml paths whose values are secrets — never tracked, compared, or displayed.
* A `*` segment matches any key at that position (e.g. every vault entry, every OAuth provider).
*/
const SECRET_CONFIG_FIELDS = [
'studio.openai_api_key',
'db.root_key',
'db.vault.*',
'auth.publishable_key',
'auth.secret_key',
'auth.jwt_secret',
'auth.email.smtp.pass',
'auth.captcha.secret',
'auth.hook.mfa_verification_attempt.secrets',
'auth.hook.password_verification_attempt.secrets',
'auth.hook.custom_access_token.secrets',
'auth.hook.send_sms.secrets',
'auth.hook.send_email.secrets',
'auth.hook.before_user_created.secrets',
'auth.sms.twilio.auth_token',
'auth.sms.twilio_verify.auth_token',
'auth.sms.messagebird.access_key',
'auth.sms.textlocal.api_key',
'auth.sms.vonage.api_secret',
'auth.external.*.secret',
'edge_runtime.secrets.*',
'experimental.s3_access_key',
'experimental.s3_secret_key',
] as const
export function isSecretConfigField(configPath: string): boolean {
const pathSegments = configPath.split('.')
return SECRET_CONFIG_FIELDS.some((secretPath) => {
const secretSegments = secretPath.split('.')
const isPrefixPattern = secretSegments[secretSegments.length - 1] === '*'
if (isPrefixPattern) {
if (pathSegments.length < secretSegments.length) return false
} else if (secretSegments.length !== pathSegments.length) {
return false
}
return secretSegments.every((segment, i) => segment === '*' || segment === pathSegments[i])
})
}
export function getFieldDefinition(configPath: string): ResolvedConfigFieldDefinition | undefined {
const definition = CONFIG_FIELD_REGISTRY[configPath]
if (!definition) return undefined
return { ...definition, configPath }
}
/**
* A config section can nest fields arbitrarily deep (e.g. `storage.analytics.max_namespaces`),
* mirroring how deep `gitHubConfigTomlSchema` itself nests. Recurse through plain objects — but not
* arrays, which are leaf values — to produce one section-prefixed dotted path per leaf, matching how
* `CONFIG_FIELD_REGISTRY` is keyed.
*/
export function getSectionFieldEntries(
section: ConfigSection,
sectionConfig: Record<string, unknown>
): Array<{ configPath: string; rawValue: unknown }> {
const entries: Array<{ configPath: string; rawValue: unknown }> = []
function walk(value: unknown, path: string[]) {
if (isRecord(value)) {
for (const [key, nestedValue] of Object.entries(value)) walk(nestedValue, [...path, key])
return
}
entries.push({
configPath: path.join('.'),
rawValue: value,
})
}
for (const [key, value] of Object.entries(sectionConfig)) walk(value, [section, key])
return entries
}
export function getConfigValue(config: Record<string, unknown>, configPath: string): unknown {
let value: unknown = config
for (const segment of configPath.split('.')) {
if (!isRecord(value) || !Object.prototype.hasOwnProperty.call(value, segment)) {
return undefined
}
value = value[segment]
}
return value
}
/**
* A redirect allow-list is a set, not a sequence: accepts either a comma-separated string or an
* array, and returns trimmed, deduped, sorted entries so two lists holding the same URLs compare
* equal regardless of how they were written.
*/
export function normalizeRedirectUrls(value: unknown): string[] {
const urls = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : []
return Array.from(
new Set(urls.filter((url): url is string => typeof url === 'string').map((url) => url.trim()))
)
.filter(Boolean)
.sort()
}
const FILE_SIZE_PATTERN = /^(\d+(?:\.\d+)?)\s*(B|KB|KIB|MB|MIB|GB|GIB)?$/i
const FILE_SIZE_UNIT_ALIASES: Record<string, StorageSizeUnits> = {
B: StorageSizeUnits.BYTES,
KB: StorageSizeUnits.KB,
KIB: StorageSizeUnits.KB,
MB: StorageSizeUnits.MB,
MIB: StorageSizeUnits.MB,
GB: StorageSizeUnits.GB,
GIB: StorageSizeUnits.GB,
}
/**
* config.toml declares storage.file_size_limit as a human string (e.g. "50MiB"); the v2 project
* config returns the same setting in bytes. Parse the former so both sides compare as bytes.
*/
function parseFileSizeToBytes(value: unknown): unknown {
if (typeof value === 'number') return value
if (typeof value !== 'string') return value
const match = FILE_SIZE_PATTERN.exec(value.trim())
if (!match) return value
const [, amount, unitSuffix] = match
const unit = unitSuffix
? FILE_SIZE_UNIT_ALIASES[unitSuffix.toUpperCase()]
: StorageSizeUnits.BYTES
if (!unit) return value
return convertToBytes(Number(amount), unit)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -1,261 +0,0 @@
import { z } from 'zod'
// This file should be temporary and will be removed once we publish a "@supabase/config" package.
// Mirrors the shape produced by parsing a project's `supabase/config.toml`.
// Every field is optional since config.toml sections are all opt-in, and
// objects allow unknown keys since users can add custom sections/providers.
export const gitHubConfigTomlSchema = z.object({
project_id: z.string().optional(),
// `code` when the config is owned by the repository, which is what lets drift detection fall back
// to a field's hosted default when config.toml is silent on it.
config_source: z.string().optional(),
api: z
.object({
enabled: z.boolean().optional(),
port: z.number().optional(),
schemas: z.array(z.string()).optional(),
extra_search_path: z.array(z.string()).optional(),
max_rows: z.number().optional(),
tls: z.object({ enabled: z.boolean().optional() }).passthrough().optional(),
})
.passthrough()
.optional(),
db: z
.object({
port: z.number().optional(),
shadow_port: z.number().optional(),
major_version: z.number().optional(),
pooler: z
.object({
enabled: z.boolean().optional(),
port: z.number().optional(),
pool_mode: z.string().optional(),
default_pool_size: z.number().optional(),
max_client_conn: z.number().optional(),
})
.passthrough()
.optional(),
migrations: z
.object({
enabled: z.boolean().optional(),
schema_paths: z.array(z.string()).optional(),
})
.passthrough()
.optional(),
seed: z
.object({
enabled: z.boolean().optional(),
sql_paths: z.array(z.string()).optional(),
})
.passthrough()
.optional(),
network_restrictions: z
.object({
enabled: z.boolean().optional(),
allowed_cidrs: z.array(z.string()).optional(),
allowed_cidrs_v6: z.array(z.string()).optional(),
})
.passthrough()
.optional(),
})
.passthrough()
.optional(),
realtime: z.object({ enabled: z.boolean().optional() }).passthrough().optional(),
studio: z
.object({
enabled: z.boolean().optional(),
port: z.number().optional(),
api_url: z.string().optional(),
openai_api_key: z.string().optional(),
})
.passthrough()
.optional(),
inbucket: z
.object({ enabled: z.boolean().optional(), port: z.number().optional() })
.passthrough()
.optional(),
storage: z
.object({
enabled: z.boolean().optional(),
file_size_limit: z.union([z.string(), z.number()]).optional(),
s3_protocol: z.object({ enabled: z.boolean().optional() }).passthrough().optional(),
analytics: z
.object({
enabled: z.boolean().optional(),
max_namespaces: z.number().optional(),
max_tables: z.number().optional(),
max_catalogs: z.number().optional(),
})
.passthrough()
.optional(),
vector: z
.object({
enabled: z.boolean().optional(),
max_buckets: z.number().optional(),
max_indexes: z.number().optional(),
})
.passthrough()
.optional(),
})
.passthrough()
.optional(),
auth: z
.object({
enabled: z.boolean().optional(),
site_url: z.string().nullable().optional(),
additional_redirect_urls: z.array(z.string()).optional(),
jwt_expiry: z.number().optional(),
enable_refresh_token_rotation: z.boolean().optional(),
refresh_token_reuse_interval: z.number().optional(),
enable_signup: z.boolean().optional(),
enable_anonymous_sign_ins: z.boolean().optional(),
enable_manual_linking: z.boolean().optional(),
minimum_password_length: z.number().optional(),
password_requirements: z.string().nullable().optional(),
rate_limit: z
.object({
email_sent: z.number().optional(),
sms_sent: z.number().optional(),
anonymous_users: z.number().optional(),
token_refresh: z.number().optional(),
sign_in_sign_ups: z.number().optional(),
token_verifications: z.number().optional(),
web3: z.number().optional(),
})
.passthrough()
.optional(),
email: z
.object({
enable_signup: z.boolean().optional(),
double_confirm_changes: z.boolean().optional(),
enable_confirmations: z.boolean().optional(),
secure_password_change: z.boolean().optional(),
max_frequency: z.string().optional(),
otp_length: z.number().optional(),
otp_expiry: z.number().optional(),
template: z
.record(
z.string(),
z
.object({ subject: z.string().optional(), content_path: z.string().optional() })
.passthrough()
)
.optional(),
})
.passthrough()
.optional(),
sms: z
.object({
enable_signup: z.boolean().optional(),
enable_confirmations: z.boolean().optional(),
template: z.string().nullable().optional(),
max_frequency: z.string().optional(),
provider: z.string().nullable().optional(),
twilio: z
.object({
enabled: z.boolean().optional(),
account_sid: z.string().nullable().optional(),
message_service_sid: z.string().nullable().optional(),
auth_token: z.string().optional(),
})
.passthrough()
.optional(),
})
.passthrough()
.optional(),
mfa: z
.object({
max_enrolled_factors: z.number().optional(),
totp: z
.object({
enroll_enabled: z.boolean().optional(),
verify_enabled: z.boolean().optional(),
})
.passthrough()
.optional(),
phone: z
.object({
enroll_enabled: z.boolean().optional(),
verify_enabled: z.boolean().optional(),
otp_length: z.number().optional(),
template: z.string().optional(),
max_frequency: z.string().optional(),
})
.passthrough()
.optional(),
})
.passthrough()
.optional(),
external: z
.record(
z.string(),
z
.object({
enabled: z.boolean().optional(),
client_id: z.string().nullable().optional(),
secret: z.string().nullable().optional(),
redirect_uri: z.string().nullable().optional(),
url: z.string().nullable().optional(),
skip_nonce_check: z.boolean().optional(),
email_optional: z.boolean().optional(),
})
.passthrough()
)
.optional(),
web3: z
.object({
solana: z.object({ enabled: z.boolean().optional() }).passthrough().optional(),
})
.passthrough()
.optional(),
third_party: z
.record(z.string(), z.object({ enabled: z.boolean().optional() }).passthrough())
.optional(),
oauth_server: z
.object({
enabled: z.boolean().optional(),
authorization_url_path: z.string().optional(),
allow_dynamic_registration: z.boolean().optional(),
})
.passthrough()
.optional(),
})
.passthrough()
.optional(),
functions: z
.record(z.string(), z.object({ verify_jwt: z.boolean().optional() }).passthrough())
.optional(),
edge_runtime: z
.object({
enabled: z.boolean().optional(),
policy: z.string().optional(),
inspector_port: z.number().optional(),
deno_version: z.number().optional(),
})
.passthrough()
.optional(),
analytics: z
.object({
enabled: z.boolean().optional(),
port: z.number().optional(),
backend: z.string().optional(),
})
.passthrough()
.optional(),
remotes: z
.record(z.string(), z.object({ project_id: z.string().optional() }).passthrough())
.optional(),
experimental: z
.object({
orioledb_version: z.string().optional(),
s3_host: z.string().optional(),
s3_region: z.string().optional(),
s3_access_key: z.string().optional(),
s3_secret_key: z.string().optional(),
})
.passthrough()
.optional(),
})
export type GitHubConfigToml = z.infer<typeof gitHubConfigTomlSchema>
@@ -77,6 +77,7 @@ export const useCreateAndExposeAPISchemaMutation = ({
await Promise.all([
queryClient.invalidateQueries({ queryKey: databaseKeys.schemas(projectRef) }),
queryClient.invalidateQueries({ queryKey: configKeys.postgrest(projectRef) }),
queryClient.invalidateQueries({ queryKey: configKeys.projectConfig(projectRef) }),
])
await onSuccess?.(data, variables, context)
},
@@ -1,6 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { configKeys } from '../config/keys'
import { authKeys } from './keys'
import type { components } from '@/data/api'
import { handleError, patch } from '@/data/fetchers'
@@ -52,8 +53,10 @@ export const useAuthConfigUpdateMutation = ({
await onSuccess?.(data, variables, context)
queryClient
.invalidateQueries({ queryKey: lintKeys.lint(projectRef) })
Promise.all([
queryClient.invalidateQueries({ queryKey: lintKeys.lint(projectRef) }),
queryClient.invalidateQueries({ queryKey: configKeys.projectConfig(projectRef) }),
])
.then(() =>
queryClient.refetchQueries({
queryKey: lintKeys.lint(projectRef),
@@ -1,6 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { configKeys } from '../config/keys'
import { authKeys } from './keys'
import type { components } from '@/data/api'
import { handleError, patch } from '@/data/fetchers'
@@ -37,7 +38,10 @@ export const useAuthHooksUpdateMutation = ({
mutationFn: (vars) => updateAuthHooks(vars),
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries({ queryKey: authKeys.authConfig(projectRef) })
await Promise.all([
queryClient.invalidateQueries({ queryKey: authKeys.authConfig(projectRef) }),
queryClient.invalidateQueries({ queryKey: configKeys.projectConfig(projectRef) }),
])
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
@@ -4,6 +4,7 @@ import { toast } from 'sonner'
import type { ProjectAuthConfigData } from './auth-config-query'
import { authKeys } from './keys'
import { type AuthTemplateResetType } from '@/components/interfaces/Auth/EmailTemplates/EmailTemplates.types'
import { configKeys } from '@/data/config/keys'
import { handleError, post } from '@/data/fetchers'
import { lintKeys } from '@/data/lint/keys'
import type { ResponseError, UseCustomMutationOptions } from '@/types'
@@ -39,10 +40,13 @@ export const useAuthTemplateResetMutation = ({
async onSuccess(data, variables, context) {
const { projectRef } = variables
queryClient.setQueryData<ProjectAuthConfigData>(authKeys.authConfig(projectRef), data)
await queryClient.invalidateQueries({
queryKey: authKeys.authConfig(projectRef),
refetchType: 'none',
})
await Promise.all([
queryClient.invalidateQueries({
queryKey: authKeys.authConfig(projectRef),
refetchType: 'none',
}),
queryClient.invalidateQueries({ queryKey: configKeys.projectConfig(projectRef) }),
])
await onSuccess?.(data, variables, context)
void queryClient
+2 -17
View File
@@ -1,8 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import type { components } from 'api-types'
import { z } from 'zod'
import { gitHubConfigTomlSchema } from '@/components/interfaces/ConfigDrift/github-config.types'
import { get, handleError } from '@/data/fetchers'
import type { UseCustomQueryOptions } from '@/types'
@@ -11,10 +9,7 @@ export type GitHubConfigVariables = {
branch?: string
}
type GithubConfigQueryResponse =
components['schemas']['GetGitHubConnectionConfigResponse_Output'] & {
config: z.infer<typeof gitHubConfigTomlSchema>
}
type GithubConfigQueryResponse = components['schemas']['GetGitHubConnectionConfigResponse_Output']
export const githubConfigKeys = {
all: ['github-config'] as const,
@@ -40,17 +35,7 @@ export async function getGitHubConfig(
)
if (error) return handleError(error)
const parsed = gitHubConfigTomlSchema.safeParse(data.config)
if (parsed.error) {
return handleError(
new Error(`Invalid response from Github config API: ${parsed.error.message}`)
)
}
return {
...data,
config: parsed.data,
}
return data
}
export const useGitHubConfigQuery = <TData = GithubConfigQueryResponse>(
@@ -50,9 +50,10 @@ export const usePostgresConfigurationUpdateMutation = ({
mutationFn: (vars) => updatePostgresConfiguration(vars),
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries({
queryKey: configKeys.postgresConfig(projectRef),
})
await Promise.all([
queryClient.invalidateQueries({ queryKey: configKeys.postgresConfig(projectRef) }),
queryClient.invalidateQueries({ queryKey: configKeys.projectConfig(projectRef) }),
])
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
@@ -67,6 +67,7 @@ export const useProjectPostgrestConfigUpdateMutation = ({
await Promise.all([
queryClient.invalidateQueries({ queryKey: configKeys.postgrest(projectRef) }),
queryClient.invalidateQueries({ queryKey: lintKeys.lint(projectRef) }),
queryClient.invalidateQueries({ queryKey: configKeys.projectConfig(projectRef) }),
])
await onSuccess?.(data, variables, context)
},
@@ -51,7 +51,10 @@ export const useProjectStorageConfigUpdateUpdateMutation = ({
mutationFn: (vars) => updateProjectStorageConfigUpdate(vars),
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries({ queryKey: configKeys.storage(projectRef) })
await Promise.all([
queryClient.invalidateQueries({ queryKey: configKeys.storage(projectRef) }),
queryClient.invalidateQueries({ queryKey: configKeys.projectConfig(projectRef) }),
])
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
@@ -1,6 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { configKeys } from '../config/keys'
import { databaseKeys } from './keys'
import type { components } from '@/data/api'
import { handleError, patch } from '@/data/fetchers'
@@ -58,7 +59,10 @@ export const usePgbouncerConfigurationUpdateMutation = ({
mutationFn: (vars) => updatePgbouncerConfiguration(vars),
async onSuccess(data, variables, context) {
const { ref } = variables
await queryClient.invalidateQueries({ queryKey: databaseKeys.pgbouncerConfig(ref) })
await Promise.all([
queryClient.invalidateQueries({ queryKey: databaseKeys.pgbouncerConfig(ref) }),
queryClient.invalidateQueries({ queryKey: configKeys.projectConfig(ref) }),
])
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
@@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { networkRestrictionKeys } from './keys'
import { configKeys } from '@/data/config/keys'
import { handleError, post } from '@/data/fetchers'
import type { ResponseError, UseCustomMutationOptions } from '@/types'
@@ -51,7 +52,10 @@ export const useNetworkRestrictionsApplyMutation = ({
mutationFn: (vars) => applyNetworkRestrictions(vars),
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries({ queryKey: networkRestrictionKeys.list(projectRef) })
await Promise.all([
queryClient.invalidateQueries({ queryKey: networkRestrictionKeys.list(projectRef) }),
queryClient.invalidateQueries({ queryKey: configKeys.projectConfig(projectRef) }),
])
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
@@ -3,6 +3,7 @@ import { toast } from 'sonner'
import { realtimeKeys } from './keys'
import type { components } from '@/data/api'
import { configKeys } from '@/data/config/keys'
import { handleError, patch } from '@/data/fetchers'
import type { ResponseError, UseCustomMutationOptions } from '@/types'
@@ -75,7 +76,10 @@ export const useRealtimeConfigurationUpdateMutation = ({
mutationFn: (vars) => updateRealtimeConfiguration(vars),
async onSuccess(data, variables, context) {
const { ref } = variables
await queryClient.invalidateQueries({ queryKey: realtimeKeys.configuration(ref) })
await Promise.all([
queryClient.invalidateQueries({ queryKey: realtimeKeys.configuration(ref) }),
queryClient.invalidateQueries({ queryKey: configKeys.projectConfig(ref) }),
])
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
@@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { sslEnforcementKeys } from './keys'
import { configKeys } from '@/data/config/keys'
import { handleError, put } from '@/data/fetchers'
import { jitDbAccessKeys } from '@/data/jit-db-access/keys'
import type { ResponseError, UseCustomMutationOptions } from '@/types'
@@ -52,6 +53,7 @@ export const useSSLEnforcementUpdateMutation = ({
// JIT DB access can report `unavailableReason: 'ssl_enforcement_required'`,
// so its status needs to be refetched whenever SSL enforcement changes.
await queryClient.invalidateQueries({ queryKey: jitDbAccessKeys.list(projectRef) })
await queryClient.invalidateQueries({ queryKey: configKeys.projectConfig(projectRef) })
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
@@ -3,6 +3,7 @@ import { toast } from 'sonner'
import { subscriptionKeys } from './keys'
import type { AddonVariantId } from './types'
import { configKeys } from '@/data/config/keys'
import { del, handleError } from '@/data/fetchers'
import type { ResponseError, UseCustomMutationOptions } from '@/types'
@@ -49,7 +50,10 @@ export const useProjectAddonRemoveMutation = ({
const { projectRef } = variables
// [Joshen] Only invalidate addons, not subscriptions, as AddOn section in
// subscription page is using AddOn react query
await queryClient.invalidateQueries({ queryKey: subscriptionKeys.addons(projectRef) })
await Promise.all([
queryClient.invalidateQueries({ queryKey: subscriptionKeys.addons(projectRef) }),
queryClient.invalidateQueries({ queryKey: configKeys.projectConfig(projectRef) }),
])
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
@@ -3,6 +3,7 @@ import { toast } from 'sonner'
import { subscriptionKeys } from './keys'
import type { AddonVariantId, ProjectAddonType } from './types'
import { configKeys } from '@/data/config/keys'
import { handleError, post } from '@/data/fetchers'
import type { ResponseError, UseCustomMutationOptions } from '@/types'
@@ -54,7 +55,10 @@ export const useProjectAddonUpdateMutation = ({
mutationFn: (vars) => updateSubscriptionAddon(vars),
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries({ queryKey: subscriptionKeys.addons(projectRef) })
await Promise.all([
queryClient.invalidateQueries({ queryKey: subscriptionKeys.addons(projectRef) }),
queryClient.invalidateQueries({ queryKey: configKeys.projectConfig(projectRef) }),
])
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
+52 -9
View File
@@ -2,8 +2,12 @@ import { useQuery } from '@tanstack/react-query'
import { useParams } from 'common'
import { useCallback, useMemo } from 'react'
import { convertProjectConfigToGitHubConfig } from '@/components/interfaces/ConfigDrift/github-config-convert'
import { getConfigDriftSummary } from '@/components/interfaces/ConfigDrift/github-config-drift'
import {
formatGitHubConfigDecodeMessage,
fromDashboardProjectConfig,
getConfigDriftSummary,
type GitHubConfigDriftSummary,
} from '@/components/interfaces/ConfigDrift/github-config-drift'
import type { Branch } from '@/data/branches/branches-query'
import { useBranchesQuery } from '@/data/branches/branches-query'
import { useGitHubConfigQuery } from '@/data/config/github-config-query'
@@ -12,6 +16,16 @@ import { useProjectGitHubConnectionQuery } from '@/data/integrations/github-conn
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { IS_PLATFORM } from '@/lib/constants'
const EMPTY_SUMMARY: GitHubConfigDriftSummary = {
driftedFields: [],
matchedFields: [],
unmanagedFields: [],
}
type ConfigDriftMemoResult =
| { status: 'success'; summary: GitHubConfigDriftSummary }
| { status: 'error'; source: 'project-config' | 'config-toml'; error: Error }
export function getGitBranchName(branch?: Branch): string | undefined {
return branch?.git_branch?.trim() || (branch?.is_default ? undefined : branch?.name?.trim())
}
@@ -59,15 +73,37 @@ export function useSelectedGitHubConfigDrift() {
[branchesRefetch, connectionRefetch, projectConfigRefetch, githubConfigRefetch]
)
const summary = useMemo(() => {
const dashboardConfig = convertProjectConfigToGitHubConfig(projectConfigQuery.data?.attributes)
const driftResult = useMemo((): ConfigDriftMemoResult => {
let dashboardConfig
try {
dashboardConfig = fromDashboardProjectConfig(projectConfigQuery.data?.attributes)
} catch (error) {
console.error('Failed to read project configuration:', error)
return {
status: 'error',
source: 'project-config',
error: new Error('Could not read the project configuration returned by the API.'),
}
}
return getConfigDriftSummary({
dashboardConfig: dashboardConfig,
const result = getConfigDriftSummary({
dashboardConfig,
githubConfig: githubConfigQuery.data?.config,
})
if (result.status === 'invalid-config') {
return {
status: 'error',
source: 'config-toml',
error: new Error(formatGitHubConfigDecodeMessage(result.issues)),
}
}
return { status: 'success', summary: result.summary }
}, [projectConfigQuery.data?.attributes, githubConfigQuery.data?.config])
const summary = driftResult.status === 'success' ? driftResult.summary : EMPTY_SUMMARY
const conversionError = driftResult.status === 'error' ? driftResult.error : undefined
const activeQueries = [
projectQuery,
...(shouldLoad ? [branchesQuery, connectionQuery] : []),
@@ -77,16 +113,23 @@ export function useSelectedGitHubConfigDrift() {
const isReady =
shouldLoad && hasConnection && projectConfigQuery.isSuccess && githubConfigQuery.isSuccess
const issueCount = summary.driftedFields.length
const queryError = activeQueries.find((query) => query.error)?.error
let errorSource: 'query' | 'project-config' | 'config-toml' | undefined = undefined
if (queryError) {
errorSource = 'query'
} else if (driftResult.status === 'error') {
errorSource = driftResult.source
}
return {
requestedGitBranch: gitBranch,
isReady,
isPending: activeQueries.some((query) => query.isPending),
isFetching: activeQueries.some((query) => query.isFetching),
isError: activeQueries.some((query) => query.isError),
error: activeQueries.find((query) => query.error)?.error,
isError: activeQueries.some((query) => query.isError) || conversionError !== undefined,
error: queryError ?? conversionError,
errorSource,
hasConfigurationIssues: isReady && issueCount > 0,
unmanagedFields: summary.unmanagedFields,
summary,
refetch,
}
+2
View File
@@ -68,6 +68,7 @@
"@stripe/stripe-js": "9.1.0",
"@stripe/sync-engine": "1.0.32",
"@supabase/auth-js": "catalog:",
"@supabase/config": "^0.9.0",
"@supabase/mcp-server-supabase": "^0.12.0",
"@supabase/pg-meta": "workspace:*",
"@supabase/realtime-js": "catalog:",
@@ -102,6 +103,7 @@
"d3-geo": "^3.1.1",
"dayjs": "^1.11.10",
"dev-tools": "workspace:*",
"effect": "4.0.0-rc.112",
"file-saver": "^2.0.5",
"framer-motion": "^11.18.2",
"fuse.js": "^7.4.0",
+145 -4
View File
@@ -1019,7 +1019,7 @@ importers:
version: 1.1.1(@types/react@19.2.14)(react@19.2.6)
'@sentry/nextjs':
specifier: 'catalog:'
version: 10.59.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(encoding@0.1.13)(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(@types/node@22.13.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(react@19.2.6)(supports-color@8.1.1)(vite@8.2.1(@types/node@22.13.14)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.77.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(webpack@5.105.4(esbuild@0.28.1))
version: 10.59.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(encoding@0.1.13)(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(@types/node@22.13.14)(babel-plugin-macros@3.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(react@19.2.6)(supports-color@8.1.1)(vite@8.2.1(@types/node@22.13.14)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.77.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(webpack@5.105.4(esbuild@0.28.1))
'@sentry/react':
specifier: ^10.27.0
version: 10.59.0(react@19.2.6)
@@ -1041,6 +1041,9 @@ importers:
'@supabase/auth-js':
specifier: 'catalog:'
version: 2.116.0
'@supabase/config':
specifier: ^0.9.0
version: 0.9.0(babel-plugin-macros@3.1.0)(effect@4.0.0-rc.112)
'@supabase/mcp-server-supabase':
specifier: ^0.12.0
version: 0.12.0(@modelcontextprotocol/server@2.0.0)(zod@3.25.76)
@@ -1143,6 +1146,9 @@ importers:
dev-tools:
specifier: workspace:*
version: link:../../packages/dev-tools
effect:
specifier: 4.0.0-rc.112
version: 4.0.0-rc.112
file-saver:
specifier: ^2.0.5
version: 2.0.5
@@ -1208,7 +1214,7 @@ importers:
version: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
nuqs:
specifier: 2.7.1
version: 2.7.1(@tanstack/react-router@1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(@types/node@22.13.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(react-router@7.18.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
version: 2.7.1(@tanstack/react-router@1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(@types/node@22.13.14)(babel-plugin-macros@3.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(react-router@7.18.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
openai:
specifier: ^4.104.0
version: 4.104.0(encoding@0.1.13)(ws@8.21.0)(zod@3.25.76)
@@ -5262,6 +5268,36 @@ packages:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4':
resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==}
cpu: [arm64]
os: [darwin]
'@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4':
resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==}
cpu: [x64]
os: [darwin]
'@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4':
resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==}
cpu: [arm64]
os: [linux]
'@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4':
resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==}
cpu: [arm]
os: [linux]
'@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4':
resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==}
cpu: [x64]
os: [linux]
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4':
resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==}
cpu: [x64]
os: [win32]
'@mswjs/interceptors@0.39.7':
resolution: {integrity: sha512-sURvQbbKsq5f8INV54YJgJEdk8oxBanqkTiXXd33rKmofFCwZLhLRszPduMZ9TA9b8/1CHc/IJmOlBHJk2Q5AQ==}
engines: {node: '>=18'}
@@ -8050,6 +8086,19 @@ packages:
cpu: [x64]
os: [win32]
'@supabase/config@0.9.0':
resolution: {integrity: sha512-uDGfiCOMPSZFln/vUZJfozBQfcVAKYl2Re0E8OfUMBtLnKkk6t84au9Ul7E02Bxm3gL5K3R5wuyH82xhtGKseA==}
engines: {node: '>=20'}
peerDependencies:
'@effect/platform-bun': '>=4.0.0-rc.111 <5'
'@effect/platform-node': '>=4.0.0-rc.111 <5'
effect: '>=4.0.0-rc.111 <5'
peerDependenciesMeta:
'@effect/platform-bun':
optional: true
'@effect/platform-node':
optional: true
'@supabase/functions-js@2.116.0':
resolution: {integrity: sha512-E+VOc2QDcni/fySqkBFiZhnoB3SGydEdZgFI6/dEAGAHx6yEhB46TN9qb2wXs+E+RSzOBV0R6dasiSlw4xlZAA==}
engines: {node: '>=22.0.0'}
@@ -11149,6 +11198,14 @@ packages:
babel-plugin-macros:
optional: true
dedent@1.7.2:
resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==}
peerDependencies:
babel-plugin-macros: ^3.1.0
peerDependenciesMeta:
babel-plugin-macros:
optional: true
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@@ -11347,6 +11404,9 @@ packages:
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
effect@4.0.0-rc.112:
resolution: {integrity: sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==}
ejs@3.1.10:
resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==}
engines: {node: '>=0.10.0'}
@@ -11935,6 +11995,10 @@ packages:
resolution: {integrity: sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==}
engines: {'0': node >=0.6.0}
fast-check@4.10.0:
resolution: {integrity: sha512-hhqQL+IJllZi3aM4TKvmCj3bywLEcycNTTLZeLhA9ttMxBrCqM07q7Di4kl+j9EWSTXvJH1+EpIgsDbF/+8H5Q==}
engines: {node: '>=12.17.0'}
fast-content-type-parse@2.0.1:
resolution: {integrity: sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==}
@@ -14566,6 +14630,13 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
msgpackr-extract@3.0.4:
resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==}
hasBin: true
msgpackr@2.1.0:
resolution: {integrity: sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==}
msw@2.11.3:
resolution: {integrity: sha512-878imp8jxIpfzuzxYfX0qqTq1IFQz/1/RBHs/PyirSjzi+xKM/RRfIpIqHSCWjH0GxidrjhgiiXC+DWXNDvT9w==}
engines: {node: '>=18'}
@@ -14766,6 +14837,10 @@ packages:
resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==}
engines: {node: '>= 6.13.0'}
node-gyp-build-optional-packages@5.2.2:
resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
hasBin: true
node-gyp-build@4.8.4:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true
@@ -15813,6 +15888,9 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
pure-rand@8.4.2:
resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==}
qs@6.16.0:
resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==}
engines: {node: '>=0.6'}
@@ -21611,6 +21689,24 @@ snapshots:
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4':
optional: true
'@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4':
optional: true
'@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4':
optional: true
'@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4':
optional: true
'@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4':
optional: true
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4':
optional: true
'@mswjs/interceptors@0.39.7':
dependencies:
'@open-draft/deferred-promise': 2.2.0
@@ -23948,7 +24044,7 @@ snapshots:
- vite
- webpack
'@sentry/nextjs@10.59.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(encoding@0.1.13)(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(@types/node@22.13.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(react@19.2.6)(supports-color@8.1.1)(vite@8.2.1(@types/node@22.13.14)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.77.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(webpack@5.105.4(esbuild@0.28.1))':
'@sentry/nextjs@10.59.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(encoding@0.1.13)(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(@types/node@22.13.14)(babel-plugin-macros@3.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(react@19.2.6)(supports-color@8.1.1)(vite@8.2.1(@types/node@22.13.14)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.77.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(webpack@5.105.4(esbuild@0.28.1))':
dependencies:
'@opentelemetry/api': 1.9.1
'@rollup/plugin-commonjs': 28.0.1(rollup@4.60.3)
@@ -24640,6 +24736,15 @@ snapshots:
'@supabase/cli-windows-x64@2.114.0':
optional: true
'@supabase/config@0.9.0(babel-plugin-macros@3.1.0)(effect@4.0.0-rc.112)':
dependencies:
'@standard-schema/spec': 1.1.0
dedent: 1.7.2(babel-plugin-macros@3.1.0)
effect: 4.0.0-rc.112
smol-toml: 1.8.0
transitivePeerDependencies:
- babel-plugin-macros
'@supabase/functions-js@2.116.0':
dependencies:
tslib: 2.8.1
@@ -28212,6 +28317,10 @@ snapshots:
optionalDependencies:
babel-plugin-macros: 3.1.0
dedent@1.7.2(babel-plugin-macros@3.1.0):
optionalDependencies:
babel-plugin-macros: 3.1.0
deep-is@0.1.4: {}
deep-rename-keys@0.2.1:
@@ -28380,6 +28489,11 @@ snapshots:
ee-first@1.1.1: {}
effect@4.0.0-rc.112:
dependencies:
fast-check: 4.10.0
msgpackr: 2.1.0
ejs@3.1.10:
dependencies:
jake: 10.8.7
@@ -29206,6 +29320,10 @@ snapshots:
extsprintf@1.4.1: {}
fast-check@4.10.0:
dependencies:
pure-rand: 8.4.2
fast-content-type-parse@2.0.1: {}
fast-content-type-parse@3.0.0: {}
@@ -32499,6 +32617,22 @@ snapshots:
ms@2.1.3: {}
msgpackr-extract@3.0.4:
dependencies:
node-gyp-build-optional-packages: 5.2.2
optionalDependencies:
'@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4
'@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4
'@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4
'@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4
'@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4
'@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4
optional: true
msgpackr@2.1.0:
optionalDependencies:
msgpackr-extract: 3.0.4
msw@2.11.3(@types/node@22.13.14)(typescript@6.0.2):
dependencies:
'@bundled-es-modules/cookie': 2.0.1
@@ -32852,6 +32986,11 @@ snapshots:
node-forge@1.4.0: {}
node-gyp-build-optional-packages@5.2.2:
dependencies:
detect-libc: 2.1.2
optional: true
node-gyp-build@4.8.4: {}
node-gyp@10.1.0(supports-color@8.1.1):
@@ -32973,7 +33112,7 @@ snapshots:
mitt: 3.0.1
next: 15.5.25(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(@types/node@22.13.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4)
nuqs@2.7.1(@tanstack/react-router@1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(@types/node@22.13.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(react-router@7.18.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6):
nuqs@2.7.1(@tanstack/react-router@1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(@types/node@22.13.14)(babel-plugin-macros@3.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(react-router@7.18.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6):
dependencies:
'@standard-schema/spec': 1.0.0
react: 19.2.6
@@ -34025,6 +34164,8 @@ snapshots:
punycode@2.3.1: {}
pure-rand@8.4.2: {}
qs@6.16.0:
dependencies:
es-define-property: 1.0.1
+1
View File
@@ -69,6 +69,7 @@ allowBuilds:
esbuild: false
icons@file:packages/icons: set this to true or false
libpg-query: false
msgpackr-extract: false
msw: false
node-pty: true
protobufjs: false