From 8422045b8677ecb69b5eb962c2a72f9f882fc6c8 Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Mon, 21 Sep 2026 16:38:37 +0200 Subject: [PATCH] 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` Screenshot 2026-09-16 at 16 26 39 ## 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. --- .../ConfigurationDriftPage.constants.test.ts | 61 + .../ConfigurationDriftPage.constants.ts | 1004 +++++++++++++++++ .../ConfigurationDriftPage.test.tsx | 35 +- .../ConfigDrift/ConfigurationDriftPage.tsx | 153 ++- .../ConfigurationDriftPage.utils.test.ts | 96 +- .../ConfigurationDriftPage.utils.ts | 240 +--- .../ConfigDrift/github-config-convert.test.ts | 177 --- .../ConfigDrift/github-config-convert.ts | 350 ------ .../ConfigDrift/github-config-drift.test.ts | 293 +++-- .../ConfigDrift/github-config-drift.ts | 244 ++-- .../github-config-field-registry.ts | 555 --------- .../ConfigDrift/github-config.types.ts | 261 ----- .../create-and-expose-api-schema-mutation.ts | 1 + .../data/auth/auth-config-update-mutation.ts | 7 +- .../data/auth/auth-hooks-update-mutation.ts | 6 +- .../data/auth/auth-template-reset-mutation.ts | 12 +- .../studio/data/config/github-config-query.ts | 19 +- .../data/config/postgres-config-mutation.ts | 7 +- ...roject-postgrest-config-update-mutation.ts | 1 + .../project-storage-config-update-mutation.ts | 5 +- .../pgbouncer-config-update-mutation.ts | 6 +- .../network-retrictions-apply-mutation.ts | 6 +- .../data/realtime/realtime-config-mutation.ts | 6 +- .../ssl-enforcement-update-mutation.ts | 2 + .../project-addon-remove-mutation.ts | 6 +- .../project-addon-update-mutation.ts | 6 +- .../studio/hooks/misc/useGitHubConfigDrift.ts | 61 +- apps/studio/package.json | 2 + pnpm-lock.yaml | 149 ++- pnpm-workspace.yaml | 1 + 30 files changed, 1873 insertions(+), 1899 deletions(-) create mode 100644 apps/studio/components/interfaces/ConfigDrift/ConfigurationDriftPage.constants.test.ts create mode 100644 apps/studio/components/interfaces/ConfigDrift/ConfigurationDriftPage.constants.ts delete mode 100644 apps/studio/components/interfaces/ConfigDrift/github-config-convert.test.ts delete mode 100644 apps/studio/components/interfaces/ConfigDrift/github-config-convert.ts delete mode 100644 apps/studio/components/interfaces/ConfigDrift/github-config-field-registry.ts delete mode 100644 apps/studio/components/interfaces/ConfigDrift/github-config.types.ts diff --git a/apps/studio/components/interfaces/ConfigDrift/ConfigurationDriftPage.constants.test.ts b/apps/studio/components/interfaces/ConfigDrift/ConfigurationDriftPage.constants.test.ts new file mode 100644 index 00000000000..2611b78817a --- /dev/null +++ b/apps/studio/components/interfaces/ConfigDrift/ConfigurationDriftPage.constants.test.ts @@ -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 { + 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')}` + ) + } + }) +}) diff --git a/apps/studio/components/interfaces/ConfigDrift/ConfigurationDriftPage.constants.ts b/apps/studio/components/interfaces/ConfigDrift/ConfigurationDriftPage.constants.ts new file mode 100644 index 00000000000..3985087290b --- /dev/null +++ b/apps/studio/components/interfaces/ConfigDrift/ConfigurationDriftPage.constants.ts @@ -0,0 +1,1004 @@ +import { fromConfigDocument, getDefaultCliConfig, type ProjectConfig } from '@supabase/config' +import { isPlainObject } from 'lodash' + +// The field-lookup and settingHref/label registry below is still hand-rolled. @supabase/config's +// `fromApiProjectConfig`/`fromConfigDocument`/`diffProjectConfig` (called from github-config-drift.ts) +// own the section/field mapping, secret omission, and comparison; what's left here has no package +// equivalent — `settingHref`/`label` are Studio-only concepts. + +export const DEFAULT_PROJECT_CONFIG = fromConfigDocument(getDefaultCliConfig()) + +export const CONFIG_SECTIONS = Object.keys(DEFAULT_PROJECT_CONFIG) as Exclude< + keyof ProjectConfig, + '_apiResponse' +>[] +export type ConfigSection = (typeof CONFIG_SECTIONS)[number] + +interface ConfigFieldDefinition { + settingHref: (projectRef: string) => string + label: string +} + +export type ResolvedConfigFieldDefinition = ConfigFieldDefinition & { configPath: string } + +export const toProjectHomepageHref = (projectRef: string) => `/project/${projectRef}` +const toAuthUrlConfigHref = (projectRef: string) => `/project/${projectRef}/auth/url-configuration` +const toAuthProvidersHref = (provider?: string) => (projectRef: string) => + `/project/${projectRef}/auth/providers${provider ? `?provider=${provider}` : ''}` +const toAuthProtectionHref = (projectRef: string) => `/project/${projectRef}/auth/protection` +const toDatabaseSettingsHref = (projectRef: string) => `/project/${projectRef}/database/settings` +const toDataApiSettingsHref = (projectRef: string) => + `/project/${projectRef}/integrations/data_api/settings` +const toStorageSettingsHref = (projectRef: string) => + `/project/${projectRef}/storage/files/settings` +const toProjectHref = (projectRef: string) => `/project/${projectRef}` +const toAuthTemplatesHref = (projectRef: string) => `/project/${projectRef}/auth/templates` +const toAuthHooksHref = (projectRef: string) => `/project/${projectRef}/auth/hooks` +const toAuthSmtpHref = (projectRef: string) => `/project/${projectRef}/auth/smtp` +const toAuthThirdPartyHref = (projectRef: string) => `/project/${projectRef}/auth/third-party` +const toJwtKeysHref = (projectRef: string) => `/project/${projectRef}/settings/jwt` +const toComputeHref = (projectRef: string) => `/project/${projectRef}/compute` +const toStorageBucketsHref = (projectRef: string) => `/project/${projectRef}/storage/files` +const toStorageAnalyticsBucketsHref = (projectRef: string) => + `/project/${projectRef}/storage/analytics` +const toStorageVectorBucketsHref = (projectRef: string) => `/project/${projectRef}/storage/vectors` + +/** + * Every trackable field across every section, keyed by its config.toml dotted path — the same shape + * `getFieldDefinition`/`getSectionFieldEntries` address. + */ +// Entries with no dedicated Studio settings page fall back to `toProjectHref`. +export const CONFIG_FIELD_REGISTRY: Record = { + 'api.auto_expose_new_tables': { + settingHref: toDataApiSettingsHref, + label: 'Auto-expose new tables', + }, + 'api.enabled': { settingHref: toDataApiSettingsHref, label: 'API enabled' }, + 'api.extra_search_path': { settingHref: toDataApiSettingsHref, label: 'Extra search path' }, + 'api.max_rows': { settingHref: toDataApiSettingsHref, label: 'Max rows' }, + 'api.schemas': { settingHref: toDataApiSettingsHref, label: 'Exposed schemas' }, + 'auth.additional_redirect_urls': { settingHref: toAuthUrlConfigHref, label: 'Redirect URLs' }, + 'auth.captcha.enabled': { settingHref: toAuthProtectionHref, label: 'Captcha enabled' }, + 'auth.captcha.provider': { settingHref: toAuthProtectionHref, label: 'Captcha provider' }, + 'auth.email.double_confirm_changes': { + settingHref: toAuthProvidersHref('email'), + label: 'Secure email change', + }, + 'auth.email.enable_confirmations': { + settingHref: toAuthProvidersHref(), + label: 'Email confirmations', + }, + 'auth.email.enable_signup': { settingHref: toAuthProvidersHref(), label: 'Email signups' }, + 'auth.email.max_frequency': { + settingHref: toAuthTemplatesHref, + label: 'Email send frequency limit', + }, + 'auth.email.notification': { settingHref: toAuthTemplatesHref, label: 'Email notifications' }, + 'auth.email.notification.email_changed.enabled': { + settingHref: toAuthTemplatesHref, + label: 'Email changed notification enabled', + }, + 'auth.email.notification.email_changed.subject': { + settingHref: toAuthTemplatesHref, + label: 'Email changed notification subject', + }, + 'auth.email.notification.identity_linked.enabled': { + settingHref: toAuthTemplatesHref, + label: 'Identity linked notification enabled', + }, + 'auth.email.notification.identity_linked.subject': { + settingHref: toAuthTemplatesHref, + label: 'Identity linked notification subject', + }, + 'auth.email.notification.identity_unlinked.enabled': { + settingHref: toAuthTemplatesHref, + label: 'Identity unlinked notification enabled', + }, + 'auth.email.notification.identity_unlinked.subject': { + settingHref: toAuthTemplatesHref, + label: 'Identity unlinked notification subject', + }, + 'auth.email.notification.mfa_factor_enrolled.enabled': { + settingHref: toAuthTemplatesHref, + label: 'MFA factor enrolled notification enabled', + }, + 'auth.email.notification.mfa_factor_enrolled.subject': { + settingHref: toAuthTemplatesHref, + label: 'MFA factor enrolled notification subject', + }, + 'auth.email.notification.mfa_factor_unenrolled.enabled': { + settingHref: toAuthTemplatesHref, + label: 'MFA factor unenrolled notification enabled', + }, + 'auth.email.notification.mfa_factor_unenrolled.subject': { + settingHref: toAuthTemplatesHref, + label: 'MFA factor unenrolled notification subject', + }, + 'auth.email.notification.password_changed.enabled': { + settingHref: toAuthTemplatesHref, + label: 'Password changed notification enabled', + }, + 'auth.email.notification.password_changed.subject': { + settingHref: toAuthTemplatesHref, + label: 'Password changed notification subject', + }, + 'auth.email.notification.phone_changed.enabled': { + settingHref: toAuthTemplatesHref, + label: 'Phone changed notification enabled', + }, + 'auth.email.notification.phone_changed.subject': { + settingHref: toAuthTemplatesHref, + label: 'Phone changed notification subject', + }, + 'auth.email.otp_expiry': { settingHref: toAuthProvidersHref('email'), label: 'Email OTP expiry' }, + 'auth.email.otp_length': { settingHref: toAuthProvidersHref('email'), label: 'Email OTP length' }, + 'auth.email.secure_password_change': { + settingHref: toAuthProvidersHref('email'), + label: 'Secure password change', + }, + 'auth.email.smtp.admin_email': { settingHref: toAuthSmtpHref, label: 'SMTP admin email' }, + 'auth.email.smtp.enabled': { settingHref: toProjectHref, label: 'Custom SMTP enabled' }, + 'auth.email.smtp.host': { settingHref: toAuthSmtpHref, label: 'SMTP host' }, + 'auth.email.smtp.port': { settingHref: toAuthSmtpHref, label: 'SMTP port' }, + 'auth.email.smtp.sender_name': { settingHref: toAuthSmtpHref, label: 'SMTP sender name' }, + 'auth.email.smtp.user': { settingHref: toAuthSmtpHref, label: 'SMTP user' }, + 'auth.email.template': { settingHref: toAuthTemplatesHref, label: 'Email templates' }, + 'auth.email.template.confirmation.subject': { + settingHref: toProjectHref, + label: 'Confirmation email subject', + }, + 'auth.email.template.email_change.subject': { + settingHref: toProjectHref, + label: 'Email change email subject', + }, + 'auth.email.template.invite.subject': { + settingHref: toProjectHref, + label: 'Invite email subject', + }, + 'auth.email.template.magic_link.subject': { + settingHref: toProjectHref, + label: 'Magic link email subject', + }, + 'auth.email.template.reauthentication.subject': { + settingHref: toProjectHref, + label: 'Reauthentication email subject', + }, + 'auth.email.template.recovery.subject': { + settingHref: toProjectHref, + label: 'Recovery email subject', + }, + 'auth.enable_anonymous_sign_ins': { + settingHref: toAuthProvidersHref(), + label: 'Anonymous sign-ins', + }, + 'auth.enable_manual_linking': { + settingHref: toAuthProvidersHref(), + label: 'Manual account linking', + }, + 'auth.enable_refresh_token_rotation': { + settingHref: toProjectHref, + label: 'Refresh token rotation', + }, + 'auth.enable_signup': { settingHref: toAuthProvidersHref(), label: 'New user signups' }, + 'auth.enabled': { settingHref: toProjectHref, label: 'Auth enabled' }, + // external__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__secret is always a secret and is never read here. + 'auth.external.apple.client_id': { + settingHref: toAuthProvidersHref('apple'), + label: 'Apple client ID', + }, + 'auth.external.apple.email_optional': { + settingHref: toAuthProvidersHref('apple'), + label: 'Apple email optional', + }, + 'auth.external.apple.enabled': { + settingHref: toAuthProvidersHref('apple'), + label: 'Apple enabled', + }, + 'auth.external.apple.redirect_uri': { + settingHref: toAuthProvidersHref('apple'), + label: 'Apple redirect URI', + }, + 'auth.external.apple.skip_nonce_check': { + settingHref: toAuthProvidersHref('apple'), + label: 'Apple skip nonce check', + }, + 'auth.external.apple.url': { settingHref: toAuthProvidersHref('apple'), label: 'Apple URL' }, + 'auth.external.azure.client_id': { + settingHref: toAuthProvidersHref('azure'), + label: 'Azure client ID', + }, + 'auth.external.azure.email_optional': { + settingHref: toAuthProvidersHref('azure'), + label: 'Azure email optional', + }, + 'auth.external.azure.enabled': { + settingHref: toAuthProvidersHref('azure'), + label: 'Azure enabled', + }, + 'auth.external.azure.redirect_uri': { + settingHref: toAuthProvidersHref('azure'), + label: 'Azure redirect URI', + }, + 'auth.external.azure.skip_nonce_check': { + settingHref: toAuthProvidersHref('azure'), + label: 'Azure skip nonce check', + }, + 'auth.external.azure.url': { settingHref: toAuthProvidersHref('azure'), label: 'Azure URL' }, + 'auth.external.bitbucket.client_id': { + settingHref: toAuthProvidersHref('bitbucket'), + label: 'Bitbucket client ID', + }, + 'auth.external.bitbucket.email_optional': { + settingHref: toAuthProvidersHref('bitbucket'), + label: 'Bitbucket email optional', + }, + 'auth.external.bitbucket.enabled': { + settingHref: toAuthProvidersHref('bitbucket'), + label: 'Bitbucket enabled', + }, + 'auth.external.bitbucket.redirect_uri': { + settingHref: toAuthProvidersHref('bitbucket'), + label: 'Bitbucket redirect URI', + }, + 'auth.external.bitbucket.skip_nonce_check': { + settingHref: toAuthProvidersHref('bitbucket'), + label: 'Bitbucket skip nonce check', + }, + 'auth.external.bitbucket.url': { + settingHref: toAuthProvidersHref('bitbucket'), + label: 'Bitbucket URL', + }, + 'auth.external.discord.client_id': { + settingHref: toAuthProvidersHref('discord'), + label: 'Discord client ID', + }, + 'auth.external.discord.email_optional': { + settingHref: toAuthProvidersHref('discord'), + label: 'Discord email optional', + }, + 'auth.external.discord.enabled': { + settingHref: toAuthProvidersHref('discord'), + label: 'Discord enabled', + }, + 'auth.external.discord.redirect_uri': { + settingHref: toAuthProvidersHref('discord'), + label: 'Discord redirect URI', + }, + 'auth.external.discord.skip_nonce_check': { + settingHref: toAuthProvidersHref('discord'), + label: 'Discord skip nonce check', + }, + 'auth.external.discord.url': { + settingHref: toAuthProvidersHref('discord'), + label: 'Discord URL', + }, + 'auth.external.facebook.client_id': { + settingHref: toAuthProvidersHref('facebook'), + label: 'Facebook client ID', + }, + 'auth.external.facebook.email_optional': { + settingHref: toAuthProvidersHref('facebook'), + label: 'Facebook email optional', + }, + 'auth.external.facebook.enabled': { + settingHref: toAuthProvidersHref('facebook'), + label: 'Facebook enabled', + }, + 'auth.external.facebook.redirect_uri': { + settingHref: toAuthProvidersHref('facebook'), + label: 'Facebook redirect URI', + }, + 'auth.external.facebook.skip_nonce_check': { + settingHref: toAuthProvidersHref('facebook'), + label: 'Facebook skip nonce check', + }, + 'auth.external.facebook.url': { + settingHref: toAuthProvidersHref('facebook'), + label: 'Facebook URL', + }, + 'auth.external.figma.client_id': { + settingHref: toAuthProvidersHref('figma'), + label: 'Figma client ID', + }, + 'auth.external.figma.email_optional': { + settingHref: toAuthProvidersHref('figma'), + label: 'Figma email optional', + }, + 'auth.external.figma.enabled': { + settingHref: toAuthProvidersHref('figma'), + label: 'Figma enabled', + }, + 'auth.external.figma.redirect_uri': { + settingHref: toAuthProvidersHref('figma'), + label: 'Figma redirect URI', + }, + 'auth.external.figma.skip_nonce_check': { + settingHref: toAuthProvidersHref('figma'), + label: 'Figma skip nonce check', + }, + 'auth.external.figma.url': { settingHref: toAuthProvidersHref('figma'), label: 'Figma URL' }, + 'auth.external.github.client_id': { + settingHref: toAuthProvidersHref('github'), + label: 'GitHub client ID', + }, + 'auth.external.github.email_optional': { + settingHref: toAuthProvidersHref('github'), + label: 'GitHub email optional', + }, + 'auth.external.github.enabled': { + settingHref: toAuthProvidersHref('github'), + label: 'GitHub enabled', + }, + 'auth.external.github.redirect_uri': { + settingHref: toAuthProvidersHref('github'), + label: 'GitHub redirect URI', + }, + 'auth.external.github.skip_nonce_check': { + settingHref: toAuthProvidersHref('github'), + label: 'GitHub skip nonce check', + }, + 'auth.external.github.url': { settingHref: toAuthProvidersHref('github'), label: 'GitHub URL' }, + 'auth.external.gitlab.client_id': { + settingHref: toAuthProvidersHref('gitlab'), + label: 'GitLab client ID', + }, + 'auth.external.gitlab.email_optional': { + settingHref: toAuthProvidersHref('gitlab'), + label: 'GitLab email optional', + }, + 'auth.external.gitlab.enabled': { + settingHref: toAuthProvidersHref('gitlab'), + label: 'GitLab enabled', + }, + 'auth.external.gitlab.redirect_uri': { + settingHref: toAuthProvidersHref('gitlab'), + label: 'GitLab redirect URI', + }, + 'auth.external.gitlab.skip_nonce_check': { + settingHref: toAuthProvidersHref('gitlab'), + label: 'GitLab skip nonce check', + }, + 'auth.external.gitlab.url': { settingHref: toAuthProvidersHref('gitlab'), label: 'GitLab URL' }, + 'auth.external.google.client_id': { + settingHref: toAuthProvidersHref('google'), + label: 'Google client ID', + }, + 'auth.external.google.email_optional': { + settingHref: toAuthProvidersHref('google'), + label: 'Google email optional', + }, + 'auth.external.google.enabled': { + settingHref: toAuthProvidersHref('google'), + label: 'Google enabled', + }, + 'auth.external.google.redirect_uri': { + settingHref: toAuthProvidersHref('google'), + label: 'Google redirect URI', + }, + 'auth.external.google.skip_nonce_check': { + settingHref: toAuthProvidersHref('google'), + label: 'Google skip nonce check', + }, + 'auth.external.google.url': { settingHref: toAuthProvidersHref('google'), label: 'Google URL' }, + 'auth.external.kakao.client_id': { + settingHref: toAuthProvidersHref('kakao'), + label: 'Kakao client ID', + }, + 'auth.external.kakao.email_optional': { + settingHref: toAuthProvidersHref('kakao'), + label: 'Kakao email optional', + }, + 'auth.external.kakao.enabled': { + settingHref: toAuthProvidersHref('kakao'), + label: 'Kakao enabled', + }, + 'auth.external.kakao.redirect_uri': { + settingHref: toAuthProvidersHref('kakao'), + label: 'Kakao redirect URI', + }, + 'auth.external.kakao.skip_nonce_check': { + settingHref: toAuthProvidersHref('kakao'), + label: 'Kakao skip nonce check', + }, + 'auth.external.kakao.url': { settingHref: toAuthProvidersHref('kakao'), label: 'Kakao URL' }, + 'auth.external.keycloak.client_id': { + settingHref: toAuthProvidersHref('keycloak'), + label: 'Keycloak client ID', + }, + 'auth.external.keycloak.email_optional': { + settingHref: toAuthProvidersHref('keycloak'), + label: 'Keycloak email optional', + }, + 'auth.external.keycloak.enabled': { + settingHref: toAuthProvidersHref('keycloak'), + label: 'Keycloak enabled', + }, + 'auth.external.keycloak.redirect_uri': { + settingHref: toAuthProvidersHref('keycloak'), + label: 'Keycloak redirect URI', + }, + 'auth.external.keycloak.skip_nonce_check': { + settingHref: toAuthProvidersHref('keycloak'), + label: 'Keycloak skip nonce check', + }, + 'auth.external.keycloak.url': { + settingHref: toAuthProvidersHref('keycloak'), + label: 'Keycloak URL', + }, + 'auth.external.linkedin_oidc.client_id': { + settingHref: toAuthProvidersHref('LinkedIn+(OIDC)'), + label: 'LinkedIn (OIDC) client ID', + }, + 'auth.external.linkedin_oidc.email_optional': { + settingHref: toAuthProvidersHref('LinkedIn+(OIDC)'), + label: 'LinkedIn (OIDC) email optional', + }, + 'auth.external.linkedin_oidc.enabled': { + settingHref: toAuthProvidersHref('LinkedIn+(OIDC)'), + label: 'LinkedIn (OIDC) enabled', + }, + 'auth.external.linkedin_oidc.redirect_uri': { + settingHref: toAuthProvidersHref('LinkedIn+(OIDC)'), + label: 'LinkedIn (OIDC) redirect URI', + }, + 'auth.external.linkedin_oidc.skip_nonce_check': { + settingHref: toAuthProvidersHref('LinkedIn+(OIDC)'), + label: 'LinkedIn (OIDC) skip nonce check', + }, + 'auth.external.linkedin_oidc.url': { + settingHref: toAuthProvidersHref('LinkedIn+(OIDC)'), + label: 'LinkedIn (OIDC) URL', + }, + 'auth.external.notion.client_id': { + settingHref: toAuthProvidersHref('notion'), + label: 'Notion client ID', + }, + 'auth.external.notion.email_optional': { + settingHref: toAuthProvidersHref('notion'), + label: 'Notion email optional', + }, + 'auth.external.notion.enabled': { + settingHref: toAuthProvidersHref('notion'), + label: 'Notion enabled', + }, + 'auth.external.notion.redirect_uri': { + settingHref: toAuthProvidersHref('notion'), + label: 'Notion redirect URI', + }, + 'auth.external.notion.skip_nonce_check': { + settingHref: toAuthProvidersHref('notion'), + label: 'Notion skip nonce check', + }, + 'auth.external.notion.url': { settingHref: toAuthProvidersHref('notion'), label: 'Notion URL' }, + 'auth.external.slack.client_id': { + settingHref: toAuthProvidersHref('slack+(deprecated)'), + label: 'Slack client ID', + }, + 'auth.external.slack.email_optional': { + settingHref: toAuthProvidersHref('slack+(deprecated)'), + label: 'Slack email optional', + }, + 'auth.external.slack.enabled': { + settingHref: toAuthProvidersHref('slack+(deprecated)'), + label: 'Slack enabled', + }, + 'auth.external.slack.skip_nonce_check': { + settingHref: toAuthProvidersHref('slack+(deprecated)'), + label: 'Slack skip nonce check', + }, + 'auth.external.slack_oidc.client_id': { + settingHref: toAuthProvidersHref('slack+(OIDC)'), + label: 'Slack (OIDC) client ID', + }, + 'auth.external.slack_oidc.email_optional': { + settingHref: toAuthProvidersHref('slack+(OIDC)'), + label: 'Slack (OIDC) email optional', + }, + 'auth.external.slack_oidc.enabled': { + settingHref: toAuthProvidersHref('slack+(OIDC)'), + label: 'Slack (OIDC) enabled', + }, + 'auth.external.slack_oidc.redirect_uri': { + settingHref: toAuthProvidersHref('slack+(OIDC)'), + label: 'Slack (OIDC) redirect URI', + }, + 'auth.external.slack_oidc.skip_nonce_check': { + settingHref: toAuthProvidersHref('slack+(OIDC)'), + label: 'Slack (OIDC) skip nonce check', + }, + 'auth.external.slack_oidc.url': { + settingHref: toAuthProvidersHref('slack+(OIDC)'), + label: 'Slack (OIDC) URL', + }, + 'auth.external.spotify.client_id': { + settingHref: toAuthProvidersHref('spotify'), + label: 'Spotify client ID', + }, + 'auth.external.spotify.email_optional': { + settingHref: toAuthProvidersHref('spotify'), + label: 'Spotify email optional', + }, + 'auth.external.spotify.enabled': { + settingHref: toAuthProvidersHref('spotify'), + label: 'Spotify enabled', + }, + 'auth.external.spotify.redirect_uri': { + settingHref: toAuthProvidersHref('spotify'), + label: 'Spotify redirect URI', + }, + 'auth.external.spotify.skip_nonce_check': { + settingHref: toAuthProvidersHref('spotify'), + label: 'Spotify skip nonce check', + }, + 'auth.external.spotify.url': { + settingHref: toAuthProvidersHref('spotify'), + label: 'Spotify URL', + }, + 'auth.external.twitch.client_id': { + settingHref: toAuthProvidersHref('twitch'), + label: 'Twitch client ID', + }, + 'auth.external.twitch.email_optional': { + settingHref: toAuthProvidersHref('twitch'), + label: 'Twitch email optional', + }, + 'auth.external.twitch.enabled': { + settingHref: toAuthProvidersHref('twitch'), + label: 'Twitch enabled', + }, + 'auth.external.twitch.redirect_uri': { + settingHref: toAuthProvidersHref('twitch'), + label: 'Twitch redirect URI', + }, + 'auth.external.twitch.skip_nonce_check': { + settingHref: toAuthProvidersHref('twitch'), + label: 'Twitch skip nonce check', + }, + 'auth.external.twitch.url': { settingHref: toAuthProvidersHref('twitch'), label: 'Twitch URL' }, + 'auth.external.twitter.client_id': { + settingHref: toAuthProvidersHref('twitter+(deprecated)'), + label: 'Twitter client ID', + }, + 'auth.external.twitter.email_optional': { + settingHref: toAuthProvidersHref('twitter+(deprecated)'), + label: 'Twitter email optional', + }, + 'auth.external.twitter.enabled': { + settingHref: toAuthProvidersHref('twitter+(deprecated)'), + label: 'Twitter enabled', + }, + 'auth.external.twitter.redirect_uri': { + settingHref: toAuthProvidersHref('twitter+(deprecated)'), + label: 'Twitter redirect URI', + }, + 'auth.external.twitter.skip_nonce_check': { + settingHref: toAuthProvidersHref('twitter+(deprecated)'), + label: 'Twitter skip nonce check', + }, + 'auth.external.twitter.url': { + settingHref: toAuthProvidersHref('twitter+(deprecated)'), + label: 'Twitter URL', + }, + 'auth.external.x.enabled': { + settingHref: toAuthProvidersHref('x+/+twitter+(oauth+2.0)'), + label: 'X enabled', + }, + 'auth.external.x.client_id': { + settingHref: toAuthProvidersHref('x+/+twitter+(oauth+2.0)'), + label: 'X client ID', + }, + 'auth.external.x.email_optional': { + settingHref: toAuthProvidersHref('x+/+twitter+(oauth+2.0)'), + label: 'X email optional', + }, + 'auth.external.x.skip_nonce_check': { + settingHref: toAuthProvidersHref('x+/+twitter+(oauth+2.0)'), + label: 'X skip nonce check', + }, + 'auth.external.x.redirect_uri': { + settingHref: toAuthProvidersHref('x+/+twitter+(oauth+2.0)'), + label: 'X redirect URI', + }, + 'auth.external.x.url': { + settingHref: toAuthProvidersHref('x+/+twitter+(oauth+2.0)'), + label: 'X URL', + }, + 'auth.external.workos.client_id': { + settingHref: toAuthProvidersHref('workos'), + label: 'WorkOS client ID', + }, + 'auth.external.workos.email_optional': { + settingHref: toAuthProvidersHref('workos'), + label: 'WorkOS email optional', + }, + 'auth.external.workos.enabled': { + settingHref: toAuthProvidersHref('workos'), + label: 'WorkOS enabled', + }, + 'auth.external.workos.redirect_uri': { + settingHref: toAuthProvidersHref('workos'), + label: 'WorkOS redirect URI', + }, + 'auth.external.workos.skip_nonce_check': { + settingHref: toAuthProvidersHref('workos'), + label: 'WorkOS skip nonce check', + }, + 'auth.external.workos.url': { settingHref: toAuthProvidersHref('workos'), label: 'WorkOS URL' }, + 'auth.external.zoom.client_id': { + settingHref: toAuthProvidersHref('zoom'), + label: 'Zoom client ID', + }, + 'auth.external.zoom.email_optional': { + settingHref: toAuthProvidersHref('zoom'), + label: 'Zoom email optional', + }, + 'auth.external.zoom.enabled': { settingHref: toAuthProvidersHref('zoom'), label: 'Zoom enabled' }, + 'auth.external.zoom.redirect_uri': { + settingHref: toAuthProvidersHref('zoom'), + label: 'Zoom redirect URI', + }, + 'auth.external.zoom.skip_nonce_check': { + settingHref: toAuthProvidersHref('zoom'), + label: 'Zoom skip nonce check', + }, + 'auth.external.zoom.url': { settingHref: toAuthProvidersHref('zoom'), label: 'Zoom URL' }, + 'auth.hook.before_user_created.enabled': { + settingHref: toProjectHref, + label: 'Before user created hook enabled', + }, + 'auth.hook.before_user_created.uri': { + settingHref: toAuthHooksHref, + label: 'Before user created hook URI', + }, + 'auth.hook.custom_access_token.enabled': { + settingHref: toProjectHref, + label: 'Custom access token hook enabled', + }, + 'auth.hook.custom_access_token.uri': { + settingHref: toAuthHooksHref, + label: 'Custom access token hook URI', + }, + 'auth.hook.mfa_verification_attempt.enabled': { + settingHref: toProjectHref, + label: 'MFA verification attempt hook enabled', + }, + 'auth.hook.mfa_verification_attempt.uri': { + settingHref: toAuthHooksHref, + label: 'MFA verification attempt hook URI', + }, + 'auth.hook.password_verification_attempt.enabled': { + settingHref: toProjectHref, + label: 'Password verification attempt hook enabled', + }, + 'auth.hook.password_verification_attempt.uri': { + settingHref: toAuthHooksHref, + label: 'Password verification attempt hook URI', + }, + 'auth.hook.send_email.enabled': { settingHref: toProjectHref, label: 'Send email hook enabled' }, + 'auth.hook.send_email.uri': { settingHref: toAuthHooksHref, label: 'Send email hook URI' }, + 'auth.hook.send_sms.enabled': { settingHref: toProjectHref, label: 'Send SMS hook enabled' }, + 'auth.hook.send_sms.uri': { settingHref: toAuthHooksHref, label: 'Send SMS hook URI' }, + 'auth.jwt_expiry': { settingHref: toProjectHref, label: 'JWT expiry' }, + 'auth.jwt_issuer': { settingHref: toJwtKeysHref, label: 'JWT issuer' }, + 'auth.mfa.max_enrolled_factors': { + settingHref: toProjectHref, + label: 'Max enrolled MFA factors', + }, + 'auth.mfa.phone.enroll_enabled': { settingHref: toProjectHref, label: 'Phone MFA enrollment' }, + 'auth.mfa.phone.max_frequency': { + settingHref: toProjectHref, + label: 'Phone MFA send frequency limit', + }, + 'auth.mfa.phone.otp_length': { settingHref: toProjectHref, label: 'Phone MFA OTP length' }, + 'auth.mfa.phone.template': { settingHref: toProjectHref, label: 'Phone MFA template' }, + 'auth.mfa.phone.verify_enabled': { settingHref: toProjectHref, label: 'Phone MFA verification' }, + 'auth.mfa.totp.enroll_enabled': { settingHref: toProjectHref, label: 'TOTP enrollment' }, + 'auth.mfa.totp.verify_enabled': { settingHref: toProjectHref, label: 'TOTP verification' }, + 'auth.mfa.web_authn.enroll_enabled': { + settingHref: toProjectHref, + label: 'WebAuthn MFA enrollment', + }, + 'auth.mfa.web_authn.verify_enabled': { + settingHref: toProjectHref, + label: 'WebAuthn MFA verification', + }, + 'auth.minimum_password_length': { + settingHref: toAuthProvidersHref(), + label: 'Minimum password length', + }, + 'auth.oauth_server.allow_dynamic_registration': { + settingHref: toProjectHref, + label: 'Allow dynamic client registration', + }, + 'auth.oauth_server.authorization_url_path': { + settingHref: toProjectHref, + label: 'OAuth authorization URL path', + }, + 'auth.oauth_server.enabled': { settingHref: toProjectHref, label: 'OAuth server enabled' }, + 'auth.password_requirements': { + settingHref: toAuthProvidersHref(), + label: 'Password requirements', + }, + 'auth.rate_limit.anonymous_users': { + settingHref: toProjectHref, + label: 'Anonymous sign-in rate limit', + }, + 'auth.rate_limit.email_sent': { settingHref: toProjectHref, label: 'Email rate limit' }, + 'auth.rate_limit.sign_in_sign_ups': { + settingHref: toProjectHref, + label: 'Sign-in/sign-up rate limit', + }, + 'auth.rate_limit.sms_sent': { settingHref: toProjectHref, label: 'SMS rate limit' }, + 'auth.rate_limit.token_refresh': { + settingHref: toProjectHref, + label: 'Token refresh rate limit', + }, + 'auth.rate_limit.token_verifications': { + settingHref: toProjectHref, + label: 'Token verification rate limit', + }, + 'auth.rate_limit.web3': { settingHref: toProjectHref, label: 'Web3 rate limit' }, + 'auth.refresh_token_reuse_interval': { + settingHref: toProjectHref, + label: 'Refresh token reuse interval', + }, + 'auth.sessions.inactivity_timeout': { + settingHref: toProjectHref, + label: 'Session inactivity timeout', + }, + 'auth.sessions.timebox': { settingHref: toProjectHref, label: 'Session timebox' }, + 'auth.signing_keys_path': { settingHref: toJwtKeysHref, label: 'Signing keys path' }, + 'auth.site_url': { settingHref: toAuthUrlConfigHref, label: 'Site URL' }, + 'auth.sms.enable_confirmations': { + settingHref: toAuthProvidersHref('phone'), + label: 'SMS confirmations', + }, + 'auth.sms.enable_signup': { settingHref: toAuthProvidersHref('phone'), label: 'Phone signups' }, + 'auth.sms.max_frequency': { + settingHref: toAuthProvidersHref('phone'), + label: 'SMS send frequency limit', + }, + 'auth.sms.messagebird.enabled': { + settingHref: toAuthProvidersHref('phone'), + label: 'MessageBird enabled', + }, + 'auth.sms.messagebird.originator': { + settingHref: toAuthProvidersHref('phone'), + label: 'MessageBird originator', + }, + 'auth.sms.otp_expiry': { settingHref: toAuthProvidersHref('phone'), label: 'SMS OTP expiry' }, + 'auth.sms.otp_length': { settingHref: toAuthProvidersHref('phone'), label: 'SMS OTP length' }, + 'auth.sms.provider': { settingHref: toAuthProvidersHref('phone'), label: 'SMS provider' }, + 'auth.sms.template': { settingHref: toAuthProvidersHref('phone'), label: 'SMS template' }, + 'auth.sms.test_otp': { settingHref: toAuthProvidersHref('phone'), label: 'Test OTP' }, + 'auth.sms.textlocal.enabled': { + settingHref: toAuthProvidersHref('phone'), + label: 'Textlocal enabled', + }, + 'auth.sms.textlocal.sender': { + settingHref: toAuthProvidersHref('phone'), + label: 'Textlocal sender', + }, + 'auth.sms.twilio.account_sid': { + settingHref: toAuthProvidersHref('phone'), + label: 'Twilio account SID', + }, + 'auth.sms.twilio.content_sid': { + settingHref: toAuthProvidersHref('phone'), + label: 'Twilio content SID', + }, + 'auth.sms.twilio.enabled': { settingHref: toAuthProvidersHref('phone'), label: 'Twilio enabled' }, + 'auth.sms.twilio.message_service_sid': { + settingHref: toAuthProvidersHref('phone'), + label: 'Twilio message service SID', + }, + 'auth.sms.twilio_verify.account_sid': { + settingHref: toAuthProvidersHref('phone'), + label: 'Twilio Verify account SID', + }, + 'auth.sms.twilio_verify.enabled': { + settingHref: toAuthProvidersHref('phone'), + label: 'Twilio Verify enabled', + }, + 'auth.sms.twilio_verify.message_service_sid': { + settingHref: toAuthProvidersHref('phone'), + label: 'Twilio Verify message service SID', + }, + 'auth.sms.vonage.api_key': { settingHref: toAuthProvidersHref('phone'), label: 'Vonage API key' }, + 'auth.sms.vonage.enabled': { settingHref: toAuthProvidersHref('phone'), label: 'Vonage enabled' }, + 'auth.sms.vonage.from': { settingHref: toAuthProvidersHref('phone'), label: 'Vonage from' }, + 'auth.third_party.auth0.enabled': { settingHref: toProjectHref, label: 'Auth0 enabled' }, + 'auth.third_party.auth0.tenant': { settingHref: toAuthThirdPartyHref, label: 'Auth0 tenant' }, + 'auth.third_party.auth0.tenant_region': { + settingHref: toAuthThirdPartyHref, + label: 'Auth0 tenant region', + }, + 'auth.third_party.aws_cognito.enabled': { + settingHref: toProjectHref, + label: 'AWS Cognito enabled', + }, + 'auth.third_party.aws_cognito.user_pool_id': { + settingHref: toAuthThirdPartyHref, + label: 'AWS Cognito user pool ID', + }, + 'auth.third_party.aws_cognito.user_pool_region': { + settingHref: toAuthThirdPartyHref, + label: 'AWS Cognito user pool region', + }, + 'auth.third_party.clerk.domain': { settingHref: toAuthThirdPartyHref, label: 'Clerk domain' }, + 'auth.third_party.clerk.enabled': { settingHref: toProjectHref, label: 'Clerk enabled' }, + 'auth.third_party.firebase.enabled': { settingHref: toProjectHref, label: 'Firebase enabled' }, + 'auth.third_party.firebase.project_id': { + settingHref: toAuthThirdPartyHref, + label: 'Firebase project ID', + }, + 'auth.third_party.workos.enabled': { settingHref: toProjectHref, label: 'WorkOS enabled' }, + 'auth.third_party.workos.issuer_url': { + settingHref: toAuthThirdPartyHref, + label: 'WorkOS issuer URL', + }, + 'auth.web3.ethereum.enabled': { settingHref: toProjectHref, label: 'Ethereum Web3 enabled' }, + 'auth.web3.solana.enabled': { settingHref: toProjectHref, label: 'Solana Web3 enabled' }, + compute: { settingHref: toComputeHref, label: 'Compute services' }, + 'db.health_timeout': { settingHref: toProjectHref, label: 'Health check timeout' }, + 'db.major_version': { settingHref: toProjectHref, label: 'Postgres major version' }, + 'db.network_restrictions.allowed_cidrs': { + settingHref: toProjectHref, + label: 'Allowed CIDRs (IPv4)', + }, + 'db.network_restrictions.allowed_cidrs_v6': { + settingHref: toProjectHref, + label: 'Allowed CIDRs (IPv6)', + }, + 'db.network_restrictions.enabled': { + settingHref: toProjectHref, + label: 'Network restrictions enabled', + }, + 'db.pooler.default_pool_size': { + settingHref: toDatabaseSettingsHref, + label: 'Default pool size', + }, + 'db.pooler.max_client_conn': { + settingHref: toDatabaseSettingsHref, + label: 'Max client connections', + }, + 'db.pooler.pool_mode': { settingHref: toProjectHref, label: 'Pool mode' }, + 'db.settings.effective_cache_size': { + settingHref: toProjectHref, + label: 'Effective cache size', + }, + 'db.settings.logical_decoding_work_mem': { + settingHref: toProjectHref, + label: 'Logical decoding work mem', + }, + 'db.settings.maintenance_work_mem': { + settingHref: toProjectHref, + label: 'Maintenance work mem', + }, + 'db.settings.max_connections': { settingHref: toProjectHref, label: 'Max connections' }, + 'db.settings.max_locks_per_transaction': { + settingHref: toProjectHref, + label: 'Max locks per transaction', + }, + 'db.settings.max_parallel_maintenance_workers': { + settingHref: toProjectHref, + label: 'Max parallel maintenance workers', + }, + 'db.settings.max_parallel_workers': { + settingHref: toProjectHref, + label: 'Max parallel workers', + }, + 'db.settings.max_parallel_workers_per_gather': { + settingHref: toProjectHref, + label: 'Max parallel workers per gather', + }, + 'db.settings.max_replication_slots': { + settingHref: toProjectHref, + label: 'Max replication slots', + }, + 'db.settings.max_slot_wal_keep_size': { + settingHref: toProjectHref, + label: 'Max slot WAL keep size', + }, + 'db.settings.max_standby_archive_delay': { + settingHref: toProjectHref, + label: 'Max standby archive delay', + }, + 'db.settings.max_standby_streaming_delay': { + settingHref: toProjectHref, + label: 'Max standby streaming delay', + }, + 'db.settings.max_wal_senders': { settingHref: toProjectHref, label: 'Max WAL senders' }, + 'db.settings.max_wal_size': { settingHref: toProjectHref, label: 'Max WAL size' }, + 'db.settings.max_worker_processes': { + settingHref: toProjectHref, + label: 'Max worker processes', + }, + 'db.settings.session_replication_role': { + settingHref: toProjectHref, + label: 'Session replication role', + }, + 'db.settings.shared_buffers': { settingHref: toProjectHref, label: 'Shared buffers' }, + 'db.settings.statement_timeout': { settingHref: toProjectHref, label: 'Statement timeout' }, + 'db.settings.track_activity_query_size': { + settingHref: toProjectHref, + label: 'Track activity query size', + }, + 'db.settings.track_commit_timestamp': { + settingHref: toProjectHref, + label: 'Track commit timestamp', + }, + 'db.settings.wal_keep_size': { settingHref: toProjectHref, label: 'WAL keep size' }, + 'db.settings.wal_sender_timeout': { settingHref: toProjectHref, label: 'WAL sender timeout' }, + 'db.settings.work_mem': { settingHref: toProjectHref, label: 'Work mem' }, + 'db.ssl_enforcement.enabled': { + settingHref: toDatabaseSettingsHref, + label: 'SSL enforcement enabled', + }, + 'experimental.webhooks.enabled': { settingHref: toProjectHref, label: 'Webhooks enabled' }, + 'storage.analytics.buckets': { + settingHref: toStorageAnalyticsBucketsHref, + label: 'Analytics buckets', + }, + 'storage.analytics.enabled': { settingHref: toProjectHref, label: 'Storage analytics enabled' }, + 'storage.analytics.max_catalogs': { settingHref: toProjectHref, label: 'Max analytics catalogs' }, + 'storage.analytics.max_namespaces': { + settingHref: toProjectHref, + label: 'Max analytics namespaces', + }, + 'storage.analytics.max_tables': { settingHref: toProjectHref, label: 'Max analytics tables' }, + 'storage.buckets': { settingHref: toStorageBucketsHref, label: 'Storage buckets' }, + 'storage.enabled': { settingHref: toProjectHref, label: 'Storage enabled' }, + // Both sides now report this as a canonical string (e.g. "50MiB") via @supabase/config's + // `fromApiProjectConfig`/`fromConfigDocument`, so no normalization is needed here anymore. + 'storage.file_size_limit': { settingHref: toStorageSettingsHref, label: 'File size limit' }, + 'storage.image_transformation.enabled': { + settingHref: toProjectHref, + label: 'Image transformation enabled', + }, + 'storage.s3_protocol.enabled': { settingHref: toProjectHref, label: 'S3 protocol enabled' }, + 'storage.vector.buckets': { settingHref: toStorageVectorBucketsHref, label: 'Vector buckets' }, + 'storage.vector.enabled': { settingHref: toProjectHref, label: 'Storage vector enabled' }, + 'storage.vector.max_buckets': { settingHref: toProjectHref, label: 'Max vector buckets' }, + 'storage.vector.max_indexes': { settingHref: toProjectHref, label: 'Max vector indexes' }, +} + +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 +): 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 +} + +function isRecord(value: unknown): value is Record { + return isPlainObject(value) +} diff --git a/apps/studio/components/interfaces/ConfigDrift/ConfigurationDriftPage.test.tsx b/apps/studio/components/interfaces/ConfigDrift/ConfigurationDriftPage.test.tsx index 5887ddae351..80fa640b5b2 100644 --- a/apps/studio/components/interfaces/ConfigDrift/ConfigurationDriftPage.test.tsx +++ b/apps/studio/components/interfaces/ConfigDrift/ConfigurationDriftPage.test.tsx @@ -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): V2ProjectConfigResponse { return { data: { @@ -114,27 +117,30 @@ function createProjectConfigResponse(auth: Record): 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): 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(, { 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 }) diff --git a/apps/studio/components/interfaces/ConfigDrift/ConfigurationDriftPage.tsx b/apps/studio/components/interfaces/ConfigDrift/ConfigurationDriftPage.tsx index 88d111ef4a0..4d1fe9ab718 100644 --- a/apps/studio/components/interfaces/ConfigDrift/ConfigurationDriftPage.tsx +++ b/apps/studio/components/interfaces/ConfigDrift/ConfigurationDriftPage.tsx @@ -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 ( -
-
- - {label} -
-
    - {values.map((value) => ( -
  • - {value} -
  • - ))} -
-
- ) -} - 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 (
@@ -155,44 +123,22 @@ function ConfigurationDriftItem({ row }: { row: ConfigurationDriftRow }) { Open setting - - {valueDiff.kind === 'list' ? ( -
1 ? 'md:grid-cols-2' : 'grid-cols-1'}`} - > - {valueDiff.onlyInDashboard.length > 0 && ( - - )} - {valueDiff.onlyInConfig.length > 0 && ( - - )} +
+
+

+ Current environment · active +

+
+            {valueDiff.dashboardValue}
+          
- ) : ( -
-
-

- Current environment · active -

-
-              {valueDiff.dashboardValue}
-            
-
-
-

config.toml · intended

-
-              {valueDiff.configValue}
-            
-
+
+

config.toml · intended

+
+            {valueDiff.configValue}
+          
- )} +
) } @@ -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 ( - +
{groups.map((group) => (
@@ -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 } 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 ( refetch()}> @@ -315,6 +275,19 @@ export function ConfigurationDriftPage() { return (
+ + This project is connected to GitHub. The next commit redeploys{' '} + config.toml and overwrite changes made + here. To keep these changes, run{' '} + supabase config pull locally. + + } + /> + {comparableSettingCount === 0 ? (
@@ -332,8 +305,8 @@ export function ConfigurationDriftPage() {

All compared settings match

- 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.

@@ -341,7 +314,17 @@ export function ConfigurationDriftPage() { )} - + + +
) } diff --git a/apps/studio/components/interfaces/ConfigDrift/ConfigurationDriftPage.utils.test.ts b/apps/studio/components/interfaces/ConfigDrift/ConfigurationDriftPage.utils.test.ts index ca08b0e8d5c..9393eef35ab 100644 --- a/apps/studio/components/interfaces/ConfigDrift/ConfigurationDriftPage.utils.test.ts +++ b/apps/studio/components/interfaces/ConfigDrift/ConfigurationDriftPage.utils.test.ts @@ -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): UnmanagedConf } } +function matchedField(overrides: Partial): 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']) }) }) diff --git a/apps/studio/components/interfaces/ConfigDrift/ConfigurationDriftPage.utils.ts b/apps/studio/components/interfaces/ConfigDrift/ConfigurationDriftPage.utils.ts index f032e33a5f2..63df6d3b121 100644 --- a/apps/studio/components/interfaces/ConfigDrift/ConfigurationDriftPage.utils.ts +++ b/apps/studio/components/interfaces/ConfigDrift/ConfigurationDriftPage.utils.ts @@ -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 = { - '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 = { - 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 = { - 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 = { 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 & 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( + fields: readonly T[], + getValue: (field: T) => unknown +): ConfigSectionGroup[] { const bySection = new Map() 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..` 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(' ') -} diff --git a/apps/studio/components/interfaces/ConfigDrift/github-config-convert.test.ts b/apps/studio/components/interfaces/ConfigDrift/github-config-convert.test.ts deleted file mode 100644 index 1109e66367d..00000000000 --- a/apps/studio/components/interfaces/ConfigDrift/github-config-convert.test.ts +++ /dev/null @@ -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' } }) - }) - }) -}) diff --git a/apps/studio/components/interfaces/ConfigDrift/github-config-convert.ts b/apps/studio/components/interfaces/ConfigDrift/github-config-convert.ts deleted file mode 100644 index 98682237699..00000000000 --- a/apps/studio/components/interfaces/ConfigDrift/github-config-convert.ts +++ /dev/null @@ -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(value: T): T | undefined { - if (value === undefined || Array.isArray(value) || typeof value !== 'object' || value === null) { - return value === undefined ? undefined : value - } - - const result: Record = {} - 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['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 => 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['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 -): NonNullable['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 -): NonNullable['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 -): NonNullable['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 -): NonNullable['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 -): NonNullable['external'] { - const providers: Record> = {} - - 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 | undefined { - return typeof value === 'object' && value !== null && !Array.isArray(value) - ? (value as Record) - : 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) -} diff --git a/apps/studio/components/interfaces/ConfigDrift/github-config-drift.test.ts b/apps/studio/components/interfaces/ConfigDrift/github-config-drift.test.ts index 276acb239b4..4c698876cac 100644 --- a/apps/studio/components/interfaces/ConfigDrift/github-config-drift.test.ts +++ b/apps/studio/components/interfaces/ConfigDrift/github-config-drift.test.ts @@ -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).' + ) + }) +}) diff --git a/apps/studio/components/interfaces/ConfigDrift/github-config-drift.ts b/apps/studio/components/interfaces/ConfigDrift/github-config-drift.ts index 7ede8c279ae..6c76afcb68b 100644 --- a/apps/studio/components/interfaces/ConfigDrift/github-config-drift.ts +++ b/apps/studio/components/interfaces/ConfigDrift/github-config-drift.ts @@ -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 } + | { 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 { + 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 | 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 +}): 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( + 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 `

` — 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}.` } diff --git a/apps/studio/components/interfaces/ConfigDrift/github-config-field-registry.ts b/apps/studio/components/interfaces/ConfigDrift/github-config-field-registry.ts deleted file mode 100644 index a6e55240320..00000000000 --- a/apps/studio/components/interfaces/ConfigDrift/github-config-field-registry.ts +++ /dev/null @@ -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[] -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> -> - -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__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__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 = { - '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 -): 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, 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 = { - 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 { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} diff --git a/apps/studio/components/interfaces/ConfigDrift/github-config.types.ts b/apps/studio/components/interfaces/ConfigDrift/github-config.types.ts deleted file mode 100644 index b943e9ba334..00000000000 --- a/apps/studio/components/interfaces/ConfigDrift/github-config.types.ts +++ /dev/null @@ -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 diff --git a/apps/studio/data/api-settings/create-and-expose-api-schema-mutation.ts b/apps/studio/data/api-settings/create-and-expose-api-schema-mutation.ts index 58a62bb083e..e5477721da9 100644 --- a/apps/studio/data/api-settings/create-and-expose-api-schema-mutation.ts +++ b/apps/studio/data/api-settings/create-and-expose-api-schema-mutation.ts @@ -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) }, diff --git a/apps/studio/data/auth/auth-config-update-mutation.ts b/apps/studio/data/auth/auth-config-update-mutation.ts index 51f6ddc7577..c949153eda3 100644 --- a/apps/studio/data/auth/auth-config-update-mutation.ts +++ b/apps/studio/data/auth/auth-config-update-mutation.ts @@ -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), diff --git a/apps/studio/data/auth/auth-hooks-update-mutation.ts b/apps/studio/data/auth/auth-hooks-update-mutation.ts index 29f1dc0db12..e12cf4ec33f 100644 --- a/apps/studio/data/auth/auth-hooks-update-mutation.ts +++ b/apps/studio/data/auth/auth-hooks-update-mutation.ts @@ -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) { diff --git a/apps/studio/data/auth/auth-template-reset-mutation.ts b/apps/studio/data/auth/auth-template-reset-mutation.ts index 4233fcf56e7..f4e63ac4c4c 100644 --- a/apps/studio/data/auth/auth-template-reset-mutation.ts +++ b/apps/studio/data/auth/auth-template-reset-mutation.ts @@ -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(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 diff --git a/apps/studio/data/config/github-config-query.ts b/apps/studio/data/config/github-config-query.ts index 803aa8e82db..862b740ec72 100644 --- a/apps/studio/data/config/github-config-query.ts +++ b/apps/studio/data/config/github-config-query.ts @@ -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 - } +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 = ( diff --git a/apps/studio/data/config/postgres-config-mutation.ts b/apps/studio/data/config/postgres-config-mutation.ts index a8769bc870d..6f04d649521 100644 --- a/apps/studio/data/config/postgres-config-mutation.ts +++ b/apps/studio/data/config/postgres-config-mutation.ts @@ -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) { diff --git a/apps/studio/data/config/project-postgrest-config-update-mutation.ts b/apps/studio/data/config/project-postgrest-config-update-mutation.ts index f216f92a940..da25288e233 100644 --- a/apps/studio/data/config/project-postgrest-config-update-mutation.ts +++ b/apps/studio/data/config/project-postgrest-config-update-mutation.ts @@ -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) }, diff --git a/apps/studio/data/config/project-storage-config-update-mutation.ts b/apps/studio/data/config/project-storage-config-update-mutation.ts index 524457c3a65..c1c0e4e4ec4 100644 --- a/apps/studio/data/config/project-storage-config-update-mutation.ts +++ b/apps/studio/data/config/project-storage-config-update-mutation.ts @@ -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) { diff --git a/apps/studio/data/database/pgbouncer-config-update-mutation.ts b/apps/studio/data/database/pgbouncer-config-update-mutation.ts index 138ceb39ec4..225181c2a0a 100644 --- a/apps/studio/data/database/pgbouncer-config-update-mutation.ts +++ b/apps/studio/data/database/pgbouncer-config-update-mutation.ts @@ -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) { diff --git a/apps/studio/data/network-restrictions/network-retrictions-apply-mutation.ts b/apps/studio/data/network-restrictions/network-retrictions-apply-mutation.ts index 3c3b33a4141..59566ac0a6f 100644 --- a/apps/studio/data/network-restrictions/network-retrictions-apply-mutation.ts +++ b/apps/studio/data/network-restrictions/network-retrictions-apply-mutation.ts @@ -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) { diff --git a/apps/studio/data/realtime/realtime-config-mutation.ts b/apps/studio/data/realtime/realtime-config-mutation.ts index 352069bc9f0..c7a2233cfa9 100644 --- a/apps/studio/data/realtime/realtime-config-mutation.ts +++ b/apps/studio/data/realtime/realtime-config-mutation.ts @@ -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) { diff --git a/apps/studio/data/ssl-enforcement/ssl-enforcement-update-mutation.ts b/apps/studio/data/ssl-enforcement/ssl-enforcement-update-mutation.ts index 8e1423debc0..90d62ebd663 100644 --- a/apps/studio/data/ssl-enforcement/ssl-enforcement-update-mutation.ts +++ b/apps/studio/data/ssl-enforcement/ssl-enforcement-update-mutation.ts @@ -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) { diff --git a/apps/studio/data/subscriptions/project-addon-remove-mutation.ts b/apps/studio/data/subscriptions/project-addon-remove-mutation.ts index 4769c0b5619..e492496a8f4 100644 --- a/apps/studio/data/subscriptions/project-addon-remove-mutation.ts +++ b/apps/studio/data/subscriptions/project-addon-remove-mutation.ts @@ -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) { diff --git a/apps/studio/data/subscriptions/project-addon-update-mutation.ts b/apps/studio/data/subscriptions/project-addon-update-mutation.ts index 3e0a2842311..4b3a9ff9fd5 100644 --- a/apps/studio/data/subscriptions/project-addon-update-mutation.ts +++ b/apps/studio/data/subscriptions/project-addon-update-mutation.ts @@ -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) { diff --git a/apps/studio/hooks/misc/useGitHubConfigDrift.ts b/apps/studio/hooks/misc/useGitHubConfigDrift.ts index f1df911284a..d27498397c5 100644 --- a/apps/studio/hooks/misc/useGitHubConfigDrift.ts +++ b/apps/studio/hooks/misc/useGitHubConfigDrift.ts @@ -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, } diff --git a/apps/studio/package.json b/apps/studio/package.json index 43f454b3dec..3670612a3f5 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46bd334b05e..09f0f6b7900 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ac63c8989d3..31c3f54f4ef 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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