Files
supabase/apps/studio/components/interfaces/ConfigDrift/github-config-convert.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

351 lines
15 KiB
TypeScript

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)
}