Files
supabase/apps/studio/components/interfaces/ConfigDrift/ConfigurationDriftPage.utils.ts
Ivan Vasilov e10f7cc808 feat: Add a config drift page in Studio (#48906)
## New Features
- Initial work for showing configuration drift in Studio
- This only works for Github-connected projects and it'll show a banner
if the project state differs from the git-tracked `config.toml`
- Currently behind a feature-flag `ConfigDrift`, enabled on local and
staging.
- There might be drift shown without changing any setting, this is
work-in-progress.

<img width="1217" height="1195" alt="Screenshot 2026-08-19 at 23 12 10"
src="https://github.com/user-attachments/assets/fb0b18d8-1a93-4595-85cc-e8b8a3462847"
/>

## How to test
1. Connect a project to a Github repo
2. Resync the branch on `/dashboard/project/_/branches`. This will
trigger deployment of the `config.toml` on your project
3. Change some settings (I recommend
`dashboard/project/_/auth/providers`
4. A banner should appear on all project pages with a link 


## Tests
- Added coverage for configuration conversion, normalization, matching,
drift detection, and unmanaged settings.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-25 13:56:40 +02:00

298 lines
10 KiB
TypeScript

import {
type GitHubConfigDriftField,
type UnmanagedConfigField,
} from '@/components/interfaces/ConfigDrift/github-config-drift'
import {
CONFIG_SECTIONS,
normalizeRedirectUrls,
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',
}
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',
experimental: 'Experimental',
}
interface ConfigurationIssueRowMeta {
settingLabel: string
settingHref: string
valueDiff: ConfigurationDriftValueDiff
}
export type ConfigurationDriftRow = Omit<GitHubConfigDriftField, 'settingHref'> &
ConfigurationIssueRowMeta & { status: 'drifted' }
type ConfigurationDriftValueDiff =
| {
kind: 'list'
onlyInDashboard: string[]
onlyInConfig: string[]
}
| {
kind: 'scalar'
dashboardValue: string
configValue: string
}
interface UnmanagedConfigRow {
configPath: string
label: string
value: string
}
export interface UnmanagedConfigSectionGroup {
section: ConfigSection
sectionLabel: string
rows: UnmanagedConfigRow[]
}
export function createConfigurationDriftRows(
fields: GitHubConfigDriftField[],
projectRef: string
): ConfigurationDriftRow[] {
return fields.map((field) => ({
...field,
status: 'drifted' as const,
settingLabel: getConfigFieldLabel(field.configPath),
settingHref: field.settingHref(projectRef),
valueDiff: createConfigurationDriftValueDiff(field),
}))
}
export function groupUnmanagedConfigFields(
fields: readonly UnmanagedConfigField[]
): UnmanagedConfigSectionGroup[] {
const bySection = new Map<ConfigSection, UnmanagedConfigRow[]>()
for (const field of fields) {
const rows = bySection.get(field.section) ?? []
rows.push({
configPath: field.configPath,
label: getConfigFieldLabel(field.configPath),
value: formatConfigFieldValue(field.configPath, field.dashboardValue),
})
bySection.set(field.section, rows)
}
return CONFIG_SECTIONS.filter((section) => bySection.has(section)).map((section) => ({
section,
sectionLabel: CONFIG_SECTION_LABELS[section],
rows: bySection.get(section)!,
}))
}
function formatConfigFieldValue(configPath: string, value: unknown): string {
const normalizedValue =
configPath === REDIRECT_URLS_CONFIG_PATH ? normalizeRedirectUrls(value) : value
if (normalizedValue === undefined || normalizedValue === null || normalizedValue === '') {
return 'Not set'
}
if (Array.isArray(normalizedValue)) {
return normalizedValue.length === 0
? 'Not set'
: normalizedValue.map((item) => formatScalarValue(item)).join('\n')
}
return formatScalarValue(normalizedValue)
}
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),
}
}
/**
* 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.
*/
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)
}
function formatScalarValue(value: unknown): string {
if (typeof value === 'string') return value || 'Not set'
if (typeof value === 'boolean') return value ? 'Enabled' : 'Disabled'
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(' ')
}