mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
chore: Use @supabase/config for the code configuration page (#50398)
How to test: 1. Connect a project to GH repo 2. Deploy the `config.toml` once 3. Change some setting in Auth 4. You should see a change in `/dashboard/project/_/settings/code-configuration` <img width="1271" height="1186" alt="Screenshot 2026-09-16 at 16 26 39" src="https://github.com/user-attachments/assets/dfc135a4-e495-489e-88fd-b760383793b4" /> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Configuration drift comparisons now use a consistent project configuration schema. - Drift details display complete current-environment and `config.toml` values, grouped by section. - Matching and unmanaged settings are organized into dedicated sections. - Configuration fields link directly to relevant Studio settings. - Added a warning that GitHub deployments overwrite local changes. - **Bug Fixes** - Configuration updates now refresh project configuration data automatically. - Improved labels and formatting for boolean and redirect URL values. - Drift errors identify invalid configuration paths and provide corrective guidance. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
be9c67a761
commit
8422045b86
30 files changed
+1873
-1899
No files matched your search
+61
@@ -0,0 +1,61 @@
|
||||
import { toProjectConfigJsonSchema } from '@supabase/config'
|
||||
import { isPlainObject } from 'lodash'
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
import { CONFIG_FIELD_REGISTRY, getFieldDefinition } from './ConfigurationDriftPage.constants'
|
||||
|
||||
function getConfigFieldPaths(): string[] {
|
||||
const schema: unknown = toProjectConfigJsonSchema()
|
||||
const sections = isRecord(schema) ? schema.properties : undefined
|
||||
if (!isRecord(sections)) return []
|
||||
|
||||
const configPaths: string[] = []
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return isPlainObject(value)
|
||||
}
|
||||
|
||||
function walk(node: unknown, path: string[]) {
|
||||
const properties = isRecord(node) ? node.properties : undefined
|
||||
if (isRecord(properties) && Object.keys(properties).length > 0) {
|
||||
for (const [key, childNode] of Object.entries(properties)) walk(childNode, [...path, key])
|
||||
return
|
||||
}
|
||||
|
||||
configPaths.push(path.join('.'))
|
||||
}
|
||||
|
||||
for (const [section, sectionNode] of Object.entries(sections)) walk(sectionNode, [section])
|
||||
|
||||
return configPaths
|
||||
}
|
||||
|
||||
describe('getFieldDefinition', () => {
|
||||
it('has a definition for every field the default project config can report', () => {
|
||||
const missingPaths = getConfigFieldPaths().filter(
|
||||
(configPath) => !getFieldDefinition(configPath)
|
||||
)
|
||||
|
||||
if (missingPaths.length > 0) {
|
||||
throw new Error(
|
||||
`CONFIG_FIELD_REGISTRY is missing a definition for:\n${missingPaths.join('\n')}`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// marked as fails because it depends on @supabase/config being fixed. Once it's fixed, this test should pass and
|
||||
// be kept for future regressions of the package.
|
||||
it.fails('has no definitions for fields the default project config cannot report', () => {
|
||||
const validPaths = new Set(getConfigFieldPaths())
|
||||
|
||||
const extraPaths = Object.keys(CONFIG_FIELD_REGISTRY).filter(
|
||||
(configPath) => !validPaths.has(configPath)
|
||||
)
|
||||
|
||||
if (extraPaths.length > 0) {
|
||||
throw new Error(
|
||||
`CONFIG_FIELD_REGISTRY has definitions for fields the default project config never reports:\n${extraPaths.join('\n')}`
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large.
Load diff
@@ -107,6 +107,9 @@ const CONNECTION: ListGitHubConnectionsResponse['connections'][number] = {
|
||||
workdir: '',
|
||||
}
|
||||
|
||||
// Field values here are chosen to match @supabase/config's CLI schema defaults (see
|
||||
// `getDefaultCliConfig`), so the only drift the tests below see is the one they introduce via
|
||||
// the `auth` param — everything else round-trips as "matched" or "unmanaged".
|
||||
function createProjectConfigResponse(auth: Record<string, unknown>): V2ProjectConfigResponse {
|
||||
return {
|
||||
data: {
|
||||
@@ -114,27 +117,30 @@ function createProjectConfigResponse(auth: Record<string, unknown>): V2ProjectCo
|
||||
type: 'project_config',
|
||||
attributes: {
|
||||
api: {
|
||||
db_extra_search_path: 'public',
|
||||
db_extra_search_path: 'public,extensions',
|
||||
db_pool: null,
|
||||
db_pool_acquisition_timeout: 10,
|
||||
db_schema: 'public',
|
||||
db_schema: 'public,graphql_public',
|
||||
max_rows: 1000,
|
||||
},
|
||||
auth,
|
||||
database: {
|
||||
major_version: 17,
|
||||
network_restrictions: {
|
||||
allowed_cidrs: [],
|
||||
allowed_cidrs: [
|
||||
{ address: '0.0.0.0/0', type: 'v4' },
|
||||
{ address: '::/0', type: 'v6' },
|
||||
],
|
||||
entitlement: 'disallowed',
|
||||
status: 'stored',
|
||||
},
|
||||
postgres_settings: {},
|
||||
ssl_enforced: true,
|
||||
ssl_enforced: false,
|
||||
},
|
||||
pooler: {
|
||||
default_pool_size: 15,
|
||||
default_pool_size: 20,
|
||||
ignore_startup_parameters: '',
|
||||
max_client_conn: 200,
|
||||
max_client_conn: 100,
|
||||
pool_mode: 'transaction',
|
||||
query_wait_timeout: 120,
|
||||
reserve_pool_size: 0,
|
||||
@@ -159,10 +165,10 @@ function createProjectConfigResponse(auth: Record<string, unknown>): V2ProjectCo
|
||||
capabilities: { iceberg_catalog: false, list_v2: true, object_versioning: false },
|
||||
features: {
|
||||
iceberg_catalog: { enabled: false, max_catalogs: 0, max_namespaces: 0, max_tables: 0 },
|
||||
image_transformation: { enabled: true },
|
||||
image_transformation: { enabled: false },
|
||||
purge_cache: { enabled: false },
|
||||
s3_protocol: { enabled: false },
|
||||
vector_buckets: { enabled: false, max_buckets: 0, max_indexes: 0 },
|
||||
s3_protocol: { enabled: true },
|
||||
vector_buckets: { enabled: true, max_buckets: 10, max_indexes: 5 },
|
||||
},
|
||||
file_size_limit: 52_428_800,
|
||||
migration_version: '0',
|
||||
@@ -270,6 +276,17 @@ describe('ConfigurationDriftPage', () => {
|
||||
expect(await screen.findByText('All compared settings match')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('shows a config.toml decode error with the offending path', async () => {
|
||||
mockConnectedProject()
|
||||
mockProjectConfig({ disable_signup: false })
|
||||
mockGitHubConfig({ api: { max_rows: 'abc' } })
|
||||
|
||||
customRender(<ConfigurationDriftPage />, { profileContext: PROFILE_CONTEXT })
|
||||
|
||||
expect(await screen.findByText('Could not read config.toml')).toBeInTheDocument()
|
||||
expect(screen.getByText(/api\.max_rows/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('renders a drift row when the dashboard and config.toml disagree', async () => {
|
||||
mockConnectedProject()
|
||||
mockProjectConfig({ disable_signup: false })
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useParams } from 'common'
|
||||
import { ArrowRight, CheckCircle2, FileWarning, Github, Minus, Plus } from 'lucide-react'
|
||||
import { ArrowRight, CheckCircle2, FileWarning, Github, Plus } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { Button, Card, Skeleton } from 'ui'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
import { CollapsibleCardSection } from 'ui-patterns/CollapsibleCardSection'
|
||||
import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational'
|
||||
|
||||
import {
|
||||
createConfigurationDriftRows,
|
||||
groupMatchedConfigFields,
|
||||
groupUnmanagedConfigFields,
|
||||
type ConfigSectionGroup,
|
||||
type ConfigurationDriftRow,
|
||||
type UnmanagedConfigSectionGroup,
|
||||
} from './ConfigurationDriftPage.utils'
|
||||
import { AlertError } from '@/components/ui/AlertError'
|
||||
import { useSelectedGitHubConfigDrift } from '@/hooks/misc/useGitHubConfigDrift'
|
||||
@@ -98,42 +100,8 @@ export function ConfigurationDriftPageSkeleton() {
|
||||
)
|
||||
}
|
||||
|
||||
function ConfigurationValuePanel({
|
||||
label,
|
||||
values,
|
||||
kind,
|
||||
}: {
|
||||
label: string
|
||||
values: string[]
|
||||
kind: 'dashboard' | 'config'
|
||||
}) {
|
||||
const Icon = kind === 'dashboard' ? Minus : Plus
|
||||
|
||||
return (
|
||||
<div className="min-w-0 rounded-md border border-border bg-surface-100 p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-foreground-light">
|
||||
<Icon
|
||||
className={kind === 'dashboard' ? 'h-3.5 w-3.5 text-warning' : 'h-3.5 w-3.5 text-brand'}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{values.map((value) => (
|
||||
<li key={value} className="break-all font-mono text-xs leading-5 text-foreground">
|
||||
{value}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConfigurationDriftItem({ row }: { row: ConfigurationDriftRow }) {
|
||||
const valueDiff = row.valueDiff
|
||||
const listDifferenceCount =
|
||||
valueDiff.kind === 'list'
|
||||
? Number(valueDiff.onlyInDashboard.length > 0) + Number(valueDiff.onlyInConfig.length > 0)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<article className="border-t border-border px-4 py-4 first:border-t-0 sm:px-5">
|
||||
@@ -155,44 +123,22 @@ function ConfigurationDriftItem({ row }: { row: ConfigurationDriftRow }) {
|
||||
<Link href={row.settingHref}>Open setting</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{valueDiff.kind === 'list' ? (
|
||||
<div
|
||||
className={`mt-4 grid gap-3 ${listDifferenceCount > 1 ? 'md:grid-cols-2' : 'grid-cols-1'}`}
|
||||
>
|
||||
{valueDiff.onlyInDashboard.length > 0 && (
|
||||
<ConfigurationValuePanel
|
||||
label="Only in current environment"
|
||||
values={valueDiff.onlyInDashboard}
|
||||
kind="dashboard"
|
||||
/>
|
||||
)}
|
||||
{valueDiff.onlyInConfig.length > 0 && (
|
||||
<ConfigurationValuePanel
|
||||
label="Only in config.toml"
|
||||
values={valueDiff.onlyInConfig}
|
||||
kind="config"
|
||||
/>
|
||||
)}
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<div className="min-w-0 rounded-md border border-border bg-surface-100 p-3">
|
||||
<p className="mb-2 text-xs font-medium text-foreground-light">
|
||||
Current environment · active
|
||||
</p>
|
||||
<pre className="whitespace-pre-wrap break-all font-mono text-xs leading-5 text-foreground">
|
||||
{valueDiff.dashboardValue}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<div className="min-w-0 rounded-md border border-border bg-surface-100 p-3">
|
||||
<p className="mb-2 text-xs font-medium text-foreground-light">
|
||||
Current environment · active
|
||||
</p>
|
||||
<pre className="whitespace-pre-wrap break-all font-mono text-xs leading-5 text-foreground">
|
||||
{valueDiff.dashboardValue}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="min-w-0 rounded-md border border-border bg-surface-100 p-3">
|
||||
<p className="mb-2 text-xs font-medium text-foreground-light">config.toml · intended</p>
|
||||
<pre className="whitespace-pre-wrap break-all font-mono text-xs leading-5 text-foreground">
|
||||
{valueDiff.configValue}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="min-w-0 rounded-md border border-border bg-surface-100 p-3">
|
||||
<p className="mb-2 text-xs font-medium text-foreground-light">config.toml · intended</p>
|
||||
<pre className="whitespace-pre-wrap break-all font-mono text-xs leading-5 text-foreground">
|
||||
{valueDiff.configValue}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -224,16 +170,21 @@ export function ConfigurationDriftResults({ rows }: { rows: ConfigurationDriftRo
|
||||
)
|
||||
}
|
||||
|
||||
function UnmanagedConfigSection({ groups }: { groups: UnmanagedConfigSectionGroup[] }) {
|
||||
function ConfigFieldSection({
|
||||
title,
|
||||
description,
|
||||
groups,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
groups: ConfigSectionGroup[]
|
||||
}) {
|
||||
const fieldCount = groups.reduce((count, group) => count + group.rows.length, 0)
|
||||
if (fieldCount === 0) return null
|
||||
|
||||
return (
|
||||
<Card className="px-4 py-4 sm:px-5">
|
||||
<CollapsibleCardSection
|
||||
title={`Not tracked in config.toml (${fieldCount})`}
|
||||
description="These settings aren't declared in config.toml, so they can't drift — shown here for visibility only."
|
||||
>
|
||||
<CollapsibleCardSection title={`${title} (${fieldCount})`} description={description}>
|
||||
<div className="space-y-4">
|
||||
{groups.map((group) => (
|
||||
<div key={group.section}>
|
||||
@@ -266,22 +217,31 @@ function UnmanagedConfigSection({ groups }: { groups: UnmanagedConfigSectionGrou
|
||||
|
||||
export function ConfigurationDriftPage() {
|
||||
const { ref: projectRef = '' } = useParams()
|
||||
const { summary, unmanagedFields, isReady, isPending, isFetching, isError, error, refetch } =
|
||||
const { summary, isReady, isPending, isFetching, isError, error, errorSource, refetch } =
|
||||
useSelectedGitHubConfigDrift()
|
||||
const driftRows = createConfigurationDriftRows(summary.driftedFields, projectRef)
|
||||
const comparableSettingCount = summary.managedCount + driftRows.length
|
||||
const unmanagedGroups = groupUnmanagedConfigFields(unmanagedFields)
|
||||
const comparableSettingCount = summary.matchedFields.length + driftRows.length
|
||||
const matchedGroups = groupMatchedConfigFields(summary.matchedFields)
|
||||
const unmanagedGroups = groupUnmanagedConfigFields(summary.unmanagedFields)
|
||||
|
||||
if (isPending) {
|
||||
return <ConfigurationDriftPageSkeleton />
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
const isConfigTomlError = errorSource === 'config-toml'
|
||||
const subject = isConfigTomlError
|
||||
? 'Could not read config.toml'
|
||||
: 'Could not check configuration drift'
|
||||
const description = isConfigTomlError
|
||||
? 'Fix the listed values on the selected GitHub branch, then refresh.'
|
||||
: 'Refresh to compare the supported settings with the selected GitHub branch again.'
|
||||
|
||||
return (
|
||||
<AlertError
|
||||
projectRef={projectRef}
|
||||
subject="Could not check configuration drift"
|
||||
description="Refresh to compare the supported settings with the selected GitHub branch again."
|
||||
subject={subject}
|
||||
description={description}
|
||||
error={error}
|
||||
additionalActions={
|
||||
<Button size="small" loading={isFetching} onClick={() => refetch()}>
|
||||
@@ -315,6 +275,19 @@ export function ConfigurationDriftPage() {
|
||||
|
||||
return (
|
||||
<main className="space-y-6">
|
||||
<Admonition
|
||||
type="warning"
|
||||
title="Config.toml will overwrite these settings on next deploy"
|
||||
description={
|
||||
<>
|
||||
This project is connected to GitHub. The next commit redeploys{' '}
|
||||
<code className="text-code-inline text-xs">config.toml</code> and overwrite changes made
|
||||
here. To keep these changes, run{' '}
|
||||
<code className="text-code-inline text-xs">supabase config pull</code> locally.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{comparableSettingCount === 0 ? (
|
||||
<Card className="flex min-h-44 items-center justify-center px-6 text-center">
|
||||
<div className="max-w-lg">
|
||||
@@ -332,8 +305,8 @@ export function ConfigurationDriftPage() {
|
||||
<CheckCircle2 className="mx-auto mb-3 h-6 w-6 text-brand" />
|
||||
<h2 className="text-sm font-medium">All compared settings match</h2>
|
||||
<p className="mt-1 text-sm text-foreground-light">
|
||||
The current environment matches all {summary.managedCount} comparable settings in this
|
||||
branch.
|
||||
The current environment matches all {summary.matchedFields.length} comparable settings
|
||||
in this branch.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -341,7 +314,17 @@ export function ConfigurationDriftPage() {
|
||||
<ConfigurationDriftResults rows={driftRows} />
|
||||
)}
|
||||
|
||||
<UnmanagedConfigSection groups={unmanagedGroups} />
|
||||
<ConfigFieldSection
|
||||
title="Matching config.toml"
|
||||
description="These settings already match between the current environment and config.toml."
|
||||
groups={matchedGroups}
|
||||
/>
|
||||
|
||||
<ConfigFieldSection
|
||||
title="Not tracked in config.toml"
|
||||
description="These settings aren't declared in config.toml, so they can't drift — shown here for visibility only."
|
||||
groups={unmanagedGroups}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
+54
-42
@@ -2,9 +2,14 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
createConfigurationDriftRows,
|
||||
groupMatchedConfigFields,
|
||||
groupUnmanagedConfigFields,
|
||||
} from './ConfigurationDriftPage.utils'
|
||||
import type { GitHubConfigDriftField, UnmanagedConfigField } from './github-config-drift'
|
||||
import type {
|
||||
GitHubConfigDriftField,
|
||||
MatchedConfigField,
|
||||
UnmanagedConfigField,
|
||||
} from './github-config-drift'
|
||||
|
||||
const PROJECT_REF = 'abcdefgh'
|
||||
|
||||
@@ -28,6 +33,15 @@ function unmanagedField(overrides: Partial<UnmanagedConfigField>): UnmanagedConf
|
||||
}
|
||||
}
|
||||
|
||||
function matchedField(overrides: Partial<MatchedConfigField>): MatchedConfigField {
|
||||
return {
|
||||
section: 'api',
|
||||
configPath: 'api.max_rows',
|
||||
value: 1000,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('createConfigurationDriftRows', () => {
|
||||
it('resolves the setting href against the project ref and marks the row as drifted', () => {
|
||||
const [row] = createConfigurationDriftRows([driftField({})], PROJECT_REF)
|
||||
@@ -51,7 +65,7 @@ describe('createConfigurationDriftRows', () => {
|
||||
PROJECT_REF
|
||||
)
|
||||
|
||||
expect(row.settingLabel).toBe('Google · Client ID')
|
||||
expect(row.settingLabel).toBe('Google client ID')
|
||||
})
|
||||
|
||||
it('falls back to a title-cased last path segment for an unrecognized config path', () => {
|
||||
@@ -63,25 +77,6 @@ describe('createConfigurationDriftRows', () => {
|
||||
expect(row.settingLabel).toBe('Unknown Setting')
|
||||
})
|
||||
|
||||
it('builds a scalar diff, formatting booleans and empty values for display', () => {
|
||||
const [row] = createConfigurationDriftRows(
|
||||
[
|
||||
driftField({
|
||||
configPath: 'auth.enable_signup',
|
||||
dashboardValue: true,
|
||||
githubValue: false,
|
||||
}),
|
||||
],
|
||||
PROJECT_REF
|
||||
)
|
||||
|
||||
expect(row.valueDiff).toEqual({
|
||||
kind: 'scalar',
|
||||
dashboardValue: 'Enabled',
|
||||
configValue: 'Disabled',
|
||||
})
|
||||
})
|
||||
|
||||
it('formats a missing scalar value as "Not set"', () => {
|
||||
const [row] = createConfigurationDriftRows(
|
||||
[driftField({ configPath: 'auth.site_url', dashboardValue: '', githubValue: undefined })],
|
||||
@@ -108,26 +103,11 @@ describe('createConfigurationDriftRows', () => {
|
||||
)
|
||||
|
||||
expect(row.valueDiff).toEqual({
|
||||
kind: 'list',
|
||||
onlyInDashboard: ['https://b.com'],
|
||||
onlyInConfig: ['https://c.com'],
|
||||
configValue: ' https://a.com \nhttps://c.com',
|
||||
dashboardValue: 'https://a.com\nhttps://b.com',
|
||||
kind: 'scalar',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports no diff entries when redirect URL lists are equal after normalization', () => {
|
||||
const [row] = createConfigurationDriftRows(
|
||||
[
|
||||
driftField({
|
||||
configPath: 'auth.additional_redirect_urls',
|
||||
dashboardValue: ['https://a.com'],
|
||||
githubValue: ['https://a.com', 'https://a.com'],
|
||||
}),
|
||||
],
|
||||
PROJECT_REF
|
||||
)
|
||||
|
||||
expect(row.valueDiff).toEqual({ kind: 'list', onlyInDashboard: [], onlyInConfig: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('groupUnmanagedConfigFields', () => {
|
||||
@@ -147,7 +127,7 @@ describe('groupUnmanagedConfigFields', () => {
|
||||
sectionLabel: 'API',
|
||||
rows: [
|
||||
{ configPath: 'api.max_rows', label: 'Max rows', value: '1000' },
|
||||
{ configPath: 'api.enabled', label: 'API enabled', value: 'Enabled' },
|
||||
{ configPath: 'api.enabled', label: 'API enabled', value: 'true' },
|
||||
],
|
||||
})
|
||||
})
|
||||
@@ -174,10 +154,42 @@ describe('groupUnmanagedConfigFields', () => {
|
||||
unmanagedField({
|
||||
section: 'auth',
|
||||
configPath: 'auth.additional_redirect_urls',
|
||||
dashboardValue: ['https://b.com', ' https://a.com ', 'https://a.com'],
|
||||
dashboardValue: ['https://b.com', 'https://a.com', 'https://a.com'],
|
||||
}),
|
||||
])
|
||||
|
||||
expect(groups[0].rows[0].value).toBe('https://a.com\nhttps://b.com')
|
||||
expect(groups[0].rows[0].value).toBe('https://b.com\nhttps://a.com\nhttps://a.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('groupMatchedConfigFields', () => {
|
||||
it('returns an empty array when there are no matched fields', () => {
|
||||
expect(groupMatchedConfigFields([])).toEqual([])
|
||||
})
|
||||
|
||||
it('groups fields from the same section into a single group', () => {
|
||||
const groups = groupMatchedConfigFields([
|
||||
matchedField({ configPath: 'api.max_rows', value: 1000 }),
|
||||
matchedField({ configPath: 'api.enabled', value: true }),
|
||||
])
|
||||
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(groups[0]).toEqual({
|
||||
section: 'api',
|
||||
sectionLabel: 'API',
|
||||
rows: [
|
||||
{ configPath: 'api.max_rows', label: 'Max rows', value: '1000' },
|
||||
{ configPath: 'api.enabled', label: 'API enabled', value: 'true' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('orders groups by CONFIG_SECTIONS order, not by input order', () => {
|
||||
const groups = groupMatchedConfigFields([
|
||||
matchedField({ section: 'storage', configPath: 'storage.enabled', value: true }),
|
||||
matchedField({ section: 'api', configPath: 'api.enabled', value: true }),
|
||||
])
|
||||
|
||||
expect(groups.map((group) => group.section)).toEqual(['api', 'storage'])
|
||||
})
|
||||
})
|
||||
@@ -1,156 +1,22 @@
|
||||
import {
|
||||
type GitHubConfigDriftField,
|
||||
type UnmanagedConfigField,
|
||||
} from '@/components/interfaces/ConfigDrift/github-config-drift'
|
||||
import { startCase } from 'lodash'
|
||||
|
||||
import {
|
||||
CONFIG_SECTIONS,
|
||||
normalizeRedirectUrls,
|
||||
getFieldDefinition,
|
||||
type ConfigSection,
|
||||
} from '@/components/interfaces/ConfigDrift/github-config-field-registry'
|
||||
|
||||
/** Keyed by config.toml path — the single identifier a drifted or unmanaged field carries. */
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
'auth.enable_signup': 'New user signups',
|
||||
'auth.enable_anonymous_sign_ins': 'Anonymous sign-ins',
|
||||
'auth.enable_manual_linking': 'Manual account linking',
|
||||
'auth.site_url': 'Site URL',
|
||||
'auth.additional_redirect_urls': 'Redirect URLs',
|
||||
'auth.email.enable_signup': 'Email signups',
|
||||
'auth.sms.enable_signup': 'Phone signups',
|
||||
'auth.email.enable_confirmations': 'Email confirmations',
|
||||
'auth.email.double_confirm_changes': 'Secure email change',
|
||||
'auth.email.otp_length': 'Email OTP length',
|
||||
'auth.email.otp_expiry': 'Email OTP expiry',
|
||||
'auth.minimum_password_length': 'Minimum password length',
|
||||
'auth.password_requirements': 'Password requirements',
|
||||
'auth.sms.provider': 'SMS provider',
|
||||
'auth.sms.enable_confirmations': 'SMS confirmations',
|
||||
'auth.sms.otp_expiry': 'SMS OTP expiry',
|
||||
'auth.sms.otp_length': 'SMS OTP length',
|
||||
'auth.sms.template': 'SMS template',
|
||||
'api.max_rows': 'Max rows',
|
||||
'storage.file_size_limit': 'File size limit',
|
||||
'api.enabled': 'API enabled',
|
||||
'api.port': 'API port',
|
||||
'api.schemas': 'Exposed schemas',
|
||||
'api.extra_search_path': 'Extra search path',
|
||||
'api.tls.enabled': 'Enforce TLS',
|
||||
'db.port': 'Database port',
|
||||
'db.shadow_port': 'Shadow database port',
|
||||
'db.major_version': 'Postgres major version',
|
||||
'db.pooler.enabled': 'Connection pooler enabled',
|
||||
'db.pooler.port': 'Pooler port',
|
||||
'db.pooler.pool_mode': 'Pool mode',
|
||||
'db.pooler.default_pool_size': 'Default pool size',
|
||||
'db.pooler.max_client_conn': 'Max client connections',
|
||||
'db.migrations.enabled': 'Migrations enabled',
|
||||
'db.migrations.schema_paths': 'Migration schema paths',
|
||||
'db.seed.enabled': 'Seed enabled',
|
||||
'db.seed.sql_paths': 'Seed file paths',
|
||||
'db.network_restrictions.enabled': 'Network restrictions enabled',
|
||||
'db.network_restrictions.allowed_cidrs': 'Allowed CIDRs (IPv4)',
|
||||
'db.network_restrictions.allowed_cidrs_v6': 'Allowed CIDRs (IPv6)',
|
||||
'realtime.enabled': 'Realtime enabled',
|
||||
'studio.enabled': 'Studio enabled',
|
||||
'studio.port': 'Studio port',
|
||||
'studio.api_url': 'Studio API URL',
|
||||
'inbucket.enabled': 'Inbucket enabled',
|
||||
'inbucket.port': 'Inbucket port',
|
||||
'storage.enabled': 'Storage enabled',
|
||||
'storage.s3_protocol.enabled': 'S3 protocol enabled',
|
||||
'storage.analytics.enabled': 'Storage analytics enabled',
|
||||
'storage.analytics.max_namespaces': 'Max analytics namespaces',
|
||||
'storage.analytics.max_tables': 'Max analytics tables',
|
||||
'storage.analytics.max_catalogs': 'Max analytics catalogs',
|
||||
'storage.vector.enabled': 'Storage vector enabled',
|
||||
'storage.vector.max_buckets': 'Max vector buckets',
|
||||
'storage.vector.max_indexes': 'Max vector indexes',
|
||||
'auth.enabled': 'Auth enabled',
|
||||
'auth.jwt_expiry': 'JWT expiry',
|
||||
'auth.enable_refresh_token_rotation': 'Refresh token rotation',
|
||||
'auth.refresh_token_reuse_interval': 'Refresh token reuse interval',
|
||||
'auth.rate_limit.email_sent': 'Email rate limit',
|
||||
'auth.rate_limit.sms_sent': 'SMS rate limit',
|
||||
'auth.rate_limit.anonymous_users': 'Anonymous sign-in rate limit',
|
||||
'auth.rate_limit.token_refresh': 'Token refresh rate limit',
|
||||
'auth.rate_limit.sign_in_sign_ups': 'Sign-in/sign-up rate limit',
|
||||
'auth.rate_limit.token_verifications': 'Token verification rate limit',
|
||||
'auth.rate_limit.web3': 'Web3 rate limit',
|
||||
'auth.email.secure_password_change': 'Secure email change',
|
||||
'auth.email.max_frequency': 'Email send frequency limit',
|
||||
'auth.sms.max_frequency': 'SMS send frequency limit',
|
||||
'auth.sms.twilio.enabled': 'Twilio enabled',
|
||||
'auth.mfa.max_enrolled_factors': 'Max enrolled MFA factors',
|
||||
'auth.mfa.totp.enroll_enabled': 'TOTP enrollment',
|
||||
'auth.mfa.totp.verify_enabled': 'TOTP verification',
|
||||
'auth.mfa.phone.enroll_enabled': 'Phone MFA enrollment',
|
||||
'auth.mfa.phone.verify_enabled': 'Phone MFA verification',
|
||||
'auth.mfa.phone.otp_length': 'Phone MFA OTP length',
|
||||
'auth.mfa.phone.template': 'Phone MFA template',
|
||||
'auth.mfa.phone.max_frequency': 'Phone MFA send frequency limit',
|
||||
'auth.web3.solana.enabled': 'Solana Web3 enabled',
|
||||
'auth.oauth_server.enabled': 'OAuth server enabled',
|
||||
'auth.oauth_server.authorization_url_path': 'OAuth authorization URL path',
|
||||
'auth.oauth_server.allow_dynamic_registration': 'Allow dynamic client registration',
|
||||
'edge_runtime.enabled': 'Edge runtime enabled',
|
||||
'edge_runtime.policy': 'Edge runtime policy',
|
||||
'edge_runtime.inspector_port': 'Edge runtime inspector port',
|
||||
'edge_runtime.deno_version': 'Deno version',
|
||||
'analytics.enabled': 'Analytics enabled',
|
||||
'analytics.port': 'Analytics port',
|
||||
'analytics.backend': 'Analytics backend',
|
||||
'experimental.orioledb_version': 'OrioleDB version',
|
||||
'experimental.s3_host': 'S3 host',
|
||||
'experimental.s3_region': 'S3 region',
|
||||
'experimental.s3_access_key': 'S3 access key',
|
||||
'experimental.s3_secret_key': 'S3 secret key',
|
||||
}
|
||||
|
||||
/** config.toml path of the redirect URL list, which renders as an added/removed diff, not a scalar. */
|
||||
const REDIRECT_URLS_CONFIG_PATH = 'auth.additional_redirect_urls'
|
||||
|
||||
const CONFIG_KEY_LABELS: Record<string, string> = {
|
||||
client_id: 'Client ID',
|
||||
email_optional: 'Email optional',
|
||||
enabled: 'Enabled',
|
||||
redirect_uri: 'Redirect URI',
|
||||
skip_nonce_check: 'Skip nonce check',
|
||||
url: 'URL',
|
||||
}
|
||||
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
apple: 'Apple',
|
||||
azure: 'Azure',
|
||||
bitbucket: 'Bitbucket',
|
||||
discord: 'Discord',
|
||||
facebook: 'Facebook',
|
||||
figma: 'Figma',
|
||||
github: 'GitHub',
|
||||
gitlab: 'GitLab',
|
||||
google: 'Google',
|
||||
kakao: 'Kakao',
|
||||
keycloak: 'Keycloak',
|
||||
linkedin_oidc: 'LinkedIn (OIDC)',
|
||||
notion: 'Notion',
|
||||
slack_oidc: 'Slack (OIDC)',
|
||||
spotify: 'Spotify',
|
||||
twitch: 'Twitch',
|
||||
workos: 'WorkOS',
|
||||
zoom: 'Zoom',
|
||||
}
|
||||
} from '@/components/interfaces/ConfigDrift/ConfigurationDriftPage.constants'
|
||||
import {
|
||||
type GitHubConfigDriftField,
|
||||
type MatchedConfigField,
|
||||
type UnmanagedConfigField,
|
||||
} from '@/components/interfaces/ConfigDrift/github-config-drift'
|
||||
|
||||
const CONFIG_SECTION_LABELS: Record<ConfigSection, string> = {
|
||||
api: 'API',
|
||||
auth: 'Auth',
|
||||
db: 'Database',
|
||||
storage: 'Storage',
|
||||
realtime: 'Realtime',
|
||||
studio: 'Studio',
|
||||
inbucket: 'Inbucket',
|
||||
functions: 'Edge Functions',
|
||||
edge_runtime: 'Edge Runtime',
|
||||
analytics: 'Analytics',
|
||||
remotes: 'Remotes',
|
||||
compute: 'Compute',
|
||||
experimental: 'Experimental',
|
||||
}
|
||||
|
||||
@@ -163,17 +29,11 @@ interface ConfigurationIssueRowMeta {
|
||||
export type ConfigurationDriftRow = Omit<GitHubConfigDriftField, 'settingHref'> &
|
||||
ConfigurationIssueRowMeta & { status: 'drifted' }
|
||||
|
||||
type ConfigurationDriftValueDiff =
|
||||
| {
|
||||
kind: 'list'
|
||||
onlyInDashboard: string[]
|
||||
onlyInConfig: string[]
|
||||
}
|
||||
| {
|
||||
kind: 'scalar'
|
||||
dashboardValue: string
|
||||
configValue: string
|
||||
}
|
||||
type ConfigurationDriftValueDiff = {
|
||||
kind: 'scalar'
|
||||
dashboardValue: string
|
||||
configValue: string
|
||||
}
|
||||
|
||||
interface UnmanagedConfigRow {
|
||||
configPath: string
|
||||
@@ -181,7 +41,7 @@ interface UnmanagedConfigRow {
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface UnmanagedConfigSectionGroup {
|
||||
export interface ConfigSectionGroup {
|
||||
section: ConfigSection
|
||||
sectionLabel: string
|
||||
rows: UnmanagedConfigRow[]
|
||||
@@ -202,7 +62,20 @@ export function createConfigurationDriftRows(
|
||||
|
||||
export function groupUnmanagedConfigFields(
|
||||
fields: readonly UnmanagedConfigField[]
|
||||
): UnmanagedConfigSectionGroup[] {
|
||||
): ConfigSectionGroup[] {
|
||||
return groupConfigFieldsBySection(fields, (field) => field.dashboardValue)
|
||||
}
|
||||
|
||||
export function groupMatchedConfigFields(
|
||||
fields: readonly MatchedConfigField[]
|
||||
): ConfigSectionGroup[] {
|
||||
return groupConfigFieldsBySection(fields, (field) => field.value)
|
||||
}
|
||||
|
||||
function groupConfigFieldsBySection<T extends { section: ConfigSection; configPath: string }>(
|
||||
fields: readonly T[],
|
||||
getValue: (field: T) => unknown
|
||||
): ConfigSectionGroup[] {
|
||||
const bySection = new Map<ConfigSection, UnmanagedConfigRow[]>()
|
||||
|
||||
for (const field of fields) {
|
||||
@@ -210,7 +83,7 @@ export function groupUnmanagedConfigFields(
|
||||
rows.push({
|
||||
configPath: field.configPath,
|
||||
label: getConfigFieldLabel(field.configPath),
|
||||
value: formatConfigFieldValue(field.configPath, field.dashboardValue),
|
||||
value: formatConfigFieldValue(getValue(field)),
|
||||
})
|
||||
bySection.set(field.section, rows)
|
||||
}
|
||||
@@ -222,9 +95,8 @@ export function groupUnmanagedConfigFields(
|
||||
}))
|
||||
}
|
||||
|
||||
function formatConfigFieldValue(configPath: string, value: unknown): string {
|
||||
const normalizedValue =
|
||||
configPath === REDIRECT_URLS_CONFIG_PATH ? normalizeRedirectUrls(value) : value
|
||||
function formatConfigFieldValue(value: unknown): string {
|
||||
const normalizedValue = value
|
||||
|
||||
if (normalizedValue === undefined || normalizedValue === null || normalizedValue === '') {
|
||||
return 'Not set'
|
||||
@@ -241,57 +113,25 @@ function formatConfigFieldValue(configPath: string, value: unknown): string {
|
||||
function createConfigurationDriftValueDiff(
|
||||
field: GitHubConfigDriftField
|
||||
): ConfigurationDriftValueDiff {
|
||||
if (field.configPath === REDIRECT_URLS_CONFIG_PATH) {
|
||||
const dashboardUrls = normalizeRedirectUrls(field.dashboardValue)
|
||||
const configUrls = normalizeRedirectUrls(field.githubValue)
|
||||
const dashboardUrlSet = new Set(dashboardUrls)
|
||||
const configUrlSet = new Set(configUrls)
|
||||
|
||||
return {
|
||||
kind: 'list',
|
||||
onlyInDashboard: dashboardUrls.filter((url) => !configUrlSet.has(url)),
|
||||
onlyInConfig: configUrls.filter((url) => !dashboardUrlSet.has(url)),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'scalar',
|
||||
dashboardValue: formatConfigFieldValue(field.configPath, field.dashboardValue),
|
||||
configValue: formatConfigFieldValue(field.configPath, field.githubValue),
|
||||
dashboardValue: formatConfigFieldValue(field.dashboardValue),
|
||||
configValue: formatConfigFieldValue(field.githubValue),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefers a hand-written label, then the `auth.external.<provider>.<key>` shape, and otherwise
|
||||
* title-cases the last path segment — never the whole path, which would leave the dots in.
|
||||
* Prefers the registry's hand-written label, and otherwise title-cases the last path segment —
|
||||
* never the whole path, which would leave the dots in.
|
||||
*/
|
||||
function getConfigFieldLabel(configPath: string): string {
|
||||
const staticLabel = FIELD_LABELS[configPath]
|
||||
if (staticLabel) return staticLabel
|
||||
|
||||
const [, section, provider, configKey] = configPath.split('.')
|
||||
if (section === 'external' && provider && configKey) {
|
||||
return `${formatProviderLabel(provider)} · ${CONFIG_KEY_LABELS[configKey] ?? titleCase(configKey)}`
|
||||
}
|
||||
|
||||
return titleCase(configPath.split('.').at(-1) ?? configPath)
|
||||
const label = getFieldDefinition(configPath)?.label
|
||||
return label ?? startCase(configPath.split('.').at(-1) ?? configPath)
|
||||
}
|
||||
|
||||
function formatScalarValue(value: unknown): string {
|
||||
if (typeof value === 'string') return value || 'Not set'
|
||||
if (typeof value === 'boolean') return value ? 'Enabled' : 'Disabled'
|
||||
if (typeof value === 'boolean') return value ? 'true' : 'false'
|
||||
if (typeof value === 'number') return String(value)
|
||||
return JSON.stringify(value, null, 2) ?? 'Not set'
|
||||
}
|
||||
|
||||
function formatProviderLabel(provider: string): string {
|
||||
return PROVIDER_LABELS[provider] ?? titleCase(provider)
|
||||
}
|
||||
|
||||
function titleCase(value: string): string {
|
||||
return value
|
||||
.split('_')
|
||||
.filter(Boolean)
|
||||
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1).toLowerCase()}`)
|
||||
.join(' ')
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { convertProjectConfigToGitHubConfig } from './github-config-convert'
|
||||
|
||||
describe('convertProjectConfigToGitHubConfig', () => {
|
||||
it('returns an empty object when no dashboard config is provided', () => {
|
||||
expect(convertProjectConfigToGitHubConfig()).toEqual({})
|
||||
})
|
||||
|
||||
it('maps registered fields onto their nested config.toml path', () => {
|
||||
const result = convertProjectConfigToGitHubConfig({
|
||||
auth: {
|
||||
site_url: 'https://example.com',
|
||||
disable_signup: false,
|
||||
mailer_otp_length: 6,
|
||||
},
|
||||
api: {
|
||||
max_rows: 1000,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
auth: {
|
||||
site_url: 'https://example.com',
|
||||
enable_signup: true,
|
||||
email: { otp_length: 6 },
|
||||
},
|
||||
api: { max_rows: 1000 },
|
||||
})
|
||||
})
|
||||
|
||||
it('inverts booleans that config.toml expresses the opposite way', () => {
|
||||
const result = convertProjectConfigToGitHubConfig({
|
||||
auth: { disable_signup: true },
|
||||
})
|
||||
|
||||
expect(result).toEqual({ auth: { enable_signup: false } })
|
||||
})
|
||||
|
||||
it('omits fields it has no conversion mapping for, secret-named or not', () => {
|
||||
const result = convertProjectConfigToGitHubConfig({
|
||||
auth: {
|
||||
sms_twilio_auth_token: 'super-secret',
|
||||
some_unregistered_field: 'value',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('skips sections that are absent from the dashboard config', () => {
|
||||
const result = convertProjectConfigToGitHubConfig({ database: { some_field: 'x' } })
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
describe('auth.additional_redirect_urls', () => {
|
||||
it('splits the comma-separated list, then dedupes, trims and sorts it', () => {
|
||||
const result = convertProjectConfigToGitHubConfig({
|
||||
auth: { uri_allow_list: 'https://b.com, https://a.com ,https://b.com' },
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
auth: { additional_redirect_urls: ['https://a.com', 'https://b.com'] },
|
||||
})
|
||||
})
|
||||
|
||||
it('maps an empty list to an empty array, not to an absent field', () => {
|
||||
const result = convertProjectConfigToGitHubConfig({ auth: { uri_allow_list: '' } })
|
||||
|
||||
expect(result).toEqual({ auth: { additional_redirect_urls: [] } })
|
||||
})
|
||||
|
||||
it('omits the field when the dashboard has no list at all', () => {
|
||||
const result = convertProjectConfigToGitHubConfig({ auth: { site_url: 'https://a.com' } })
|
||||
|
||||
expect(result).toEqual({ auth: { site_url: 'https://a.com' } })
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves order-sensitive comma lists in their original order', () => {
|
||||
// Only the redirect allow-list is a set — `extra_search_path` order is meaningful to Postgres.
|
||||
const result = convertProjectConfigToGitHubConfig({
|
||||
api: { db_extra_search_path: 'public, extensions, auth' },
|
||||
})
|
||||
|
||||
expect(result).toEqual({ api: { extra_search_path: ['public', 'extensions', 'auth'] } })
|
||||
})
|
||||
|
||||
describe('db.network_restrictions', () => {
|
||||
it('derives enabled from status and splits allowed CIDRs by type', () => {
|
||||
const result = convertProjectConfigToGitHubConfig({
|
||||
database: {
|
||||
network_restrictions: {
|
||||
status: 'applied',
|
||||
allowed_cidrs: [
|
||||
{ type: 'v4', address: '10.0.0.0/24' },
|
||||
{ type: 'v6', address: '::1/128' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
db: {
|
||||
network_restrictions: {
|
||||
enabled: true,
|
||||
allowed_cidrs: ['10.0.0.0/24'],
|
||||
allowed_cidrs_v6: ['::1/128'],
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('db.pooler', () => {
|
||||
it('maps the top-level dashboard pooler section under db.pooler', () => {
|
||||
const result = convertProjectConfigToGitHubConfig({
|
||||
pooler: { pool_mode: 'transaction', default_pool_size: 20, max_client_conn: 100 },
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
db: { pooler: { pool_mode: 'transaction', default_pool_size: 20, max_client_conn: 100 } },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('storage', () => {
|
||||
it('maps file_size_limit and renames feature flags onto their config.toml fields', () => {
|
||||
const result = convertProjectConfigToGitHubConfig({
|
||||
storage: {
|
||||
file_size_limit: 52428800,
|
||||
features: {
|
||||
s3_protocol: { enabled: true },
|
||||
iceberg_catalog: {
|
||||
enabled: true,
|
||||
max_namespaces: 10,
|
||||
max_tables: 100,
|
||||
max_catalogs: 5,
|
||||
},
|
||||
vector_buckets: { enabled: false, max_buckets: 2, max_indexes: 4 },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
storage: {
|
||||
file_size_limit: 52428800,
|
||||
s3_protocol: { enabled: true },
|
||||
analytics: { enabled: true, max_namespaces: 10, max_tables: 100, max_catalogs: 5 },
|
||||
vector: { enabled: false, max_buckets: 2, max_indexes: 4 },
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('auth.password_requirements', () => {
|
||||
it('maps NO_REQUIRED_CHARS and null to an empty string', () => {
|
||||
expect(
|
||||
convertProjectConfigToGitHubConfig({
|
||||
auth: { password_required_characters: 'NO_REQUIRED_CHARS' },
|
||||
})
|
||||
).toEqual({ auth: { password_requirements: '' } })
|
||||
|
||||
expect(
|
||||
convertProjectConfigToGitHubConfig({ auth: { password_required_characters: null } })
|
||||
).toEqual({ auth: { password_requirements: '' } })
|
||||
})
|
||||
|
||||
it('passes through any other value unchanged', () => {
|
||||
const result = convertProjectConfigToGitHubConfig({
|
||||
auth: { password_required_characters: 'abcdefgABCDEFG01234' },
|
||||
})
|
||||
|
||||
expect(result).toEqual({ auth: { password_requirements: 'abcdefgABCDEFG01234' } })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,350 +0,0 @@
|
||||
import {
|
||||
EXTERNAL_AUTH_PROVIDERS,
|
||||
EXTERNAL_AUTH_PROVIDERS_WITH_URL,
|
||||
normalizeRedirectUrls,
|
||||
type ConfigDriftDashboardConfig,
|
||||
} from './github-config-field-registry'
|
||||
import { type GitHubConfigToml } from './github-config.types'
|
||||
|
||||
/**
|
||||
* Reshapes a project's dashboard config (sections keyed by section name, fields in their
|
||||
* dashboard-native naming, e.g. from `GET /v1/projects/:ref/config`) into the nested, dotted-path
|
||||
* shape of a parsed config.toml.
|
||||
*
|
||||
* Not every dashboard field has a config.toml counterpart — untracked fields are simply never read
|
||||
* here. See the comments throughout for renames/inversions/unit conversions and for fields that
|
||||
* were deliberately left out because config.toml has no equivalent.
|
||||
*/
|
||||
export function convertProjectConfigToGitHubConfig(
|
||||
dashboardConfig?: ConfigDriftDashboardConfig
|
||||
): GitHubConfigToml {
|
||||
if (!dashboardConfig) return {}
|
||||
|
||||
const database = dashboardConfig.database
|
||||
|
||||
const config: GitHubConfigToml = {
|
||||
db: {
|
||||
network_restrictions: convertNetworkRestrictions(database?.network_restrictions),
|
||||
pooler: convertPooler(dashboardConfig.pooler),
|
||||
},
|
||||
api: convertApi(dashboardConfig.api),
|
||||
auth: convertAuth(dashboardConfig.auth),
|
||||
storage: convertStorage(dashboardConfig.storage),
|
||||
// realtime: gitHubConfigTomlSchema only models `realtime.enabled`, and the dashboard's realtime
|
||||
// section (private_only, max_concurrent_users, max_events_per_second, ...) never provides one —
|
||||
// there is nothing to map.
|
||||
}
|
||||
// database.ssl_enforced: dashboard-only security toggle, not managed via config.toml
|
||||
// database.postgres_settings: user-set Postgres GUC overrides, applied directly to the database
|
||||
// rather than declared in config.toml
|
||||
|
||||
return pruneUndefined(config) ?? {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every convertX helper always returns every field it knows about, most of them `undefined` when
|
||||
* the source dashboard config didn't have that value. Recursively drop `undefined` leaves and the
|
||||
* empty objects left behind, so callers only see the fields that actually had a value.
|
||||
*/
|
||||
function pruneUndefined<T>(value: T): T | undefined {
|
||||
if (value === undefined || Array.isArray(value) || typeof value !== 'object' || value === null) {
|
||||
return value === undefined ? undefined : value
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, nestedValue] of Object.entries(value)) {
|
||||
const pruned = pruneUndefined(nestedValue)
|
||||
if (pruned !== undefined) result[key] = pruned
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? (result as T) : undefined
|
||||
}
|
||||
|
||||
function convertNetworkRestrictions(
|
||||
value: unknown
|
||||
): NonNullable<NonNullable<GitHubConfigToml['db']>['network_restrictions']> | undefined {
|
||||
const restrictions = asRecord(value)
|
||||
if (!restrictions) return undefined
|
||||
|
||||
const cidrs = Array.isArray(restrictions.allowed_cidrs) ? restrictions.allowed_cidrs : undefined
|
||||
const addressesOfType = (type: string) =>
|
||||
cidrs
|
||||
?.map((cidr) => asRecord(cidr))
|
||||
.filter((cidr): cidr is Record<string, unknown> => cidr?.type === type)
|
||||
.map((cidr) => asString(cidr.address))
|
||||
.filter((address): address is string => address !== undefined)
|
||||
|
||||
return {
|
||||
// dashboard has no single boolean — derived from `status === 'applied'`. `entitlement` (plan
|
||||
// gating) and `status` (rollout state) have no config.toml equivalent.
|
||||
enabled: restrictions.status === undefined ? undefined : restrictions.status === 'applied',
|
||||
allowed_cidrs: addressesOfType('v4'),
|
||||
allowed_cidrs_v6: addressesOfType('v6'),
|
||||
}
|
||||
}
|
||||
|
||||
function convertPooler(
|
||||
value: unknown
|
||||
): NonNullable<NonNullable<GitHubConfigToml['db']>['pooler']> | undefined {
|
||||
// dashboard's "pooler" is a top-level section; config.toml nests it under `db.pooler`
|
||||
const pooler = asRecord(value)
|
||||
if (!pooler) return undefined
|
||||
|
||||
return {
|
||||
pool_mode: asString(pooler.pool_mode),
|
||||
default_pool_size: asNumber(pooler.default_pool_size),
|
||||
max_client_conn: asNumber(pooler.max_client_conn),
|
||||
// ignore_startup_parameters, server_idle_timeout, server_lifetime, query_wait_timeout,
|
||||
// reserve_pool_size: Supavisor-only settings, no config.toml field.
|
||||
}
|
||||
}
|
||||
|
||||
function convertApi(value: unknown): GitHubConfigToml['api'] {
|
||||
const api = asRecord(value)
|
||||
if (!api) return undefined
|
||||
|
||||
return {
|
||||
max_rows: asNumber(api.max_rows),
|
||||
schemas: splitCommaList(api.db_schema),
|
||||
extra_search_path: splitCommaList(api.db_extra_search_path),
|
||||
// db_pool, db_pool_acquisition_timeout: no config.toml field.
|
||||
}
|
||||
}
|
||||
|
||||
function convertAuth(value: unknown): GitHubConfigToml['auth'] {
|
||||
const auth = asRecord(value)
|
||||
if (!auth) return undefined
|
||||
|
||||
return {
|
||||
site_url: asString(auth.site_url),
|
||||
// uri_allow_list is a comma-separated string on the dashboard; config.toml wants a string[]
|
||||
additional_redirect_urls: convertRedirectUrls(auth.uri_allow_list),
|
||||
jwt_expiry: asNumber(auth.jwt_exp),
|
||||
// disable_signup -> enable_signup (inverted boolean)
|
||||
enable_signup: invertBoolean(auth.disable_signup),
|
||||
enable_manual_linking: asBoolean(auth.security_manual_linking_enabled),
|
||||
// refresh_token_rotation_enabled -> enable_refresh_token_rotation (renamed)
|
||||
enable_refresh_token_rotation: asBoolean(auth.refresh_token_rotation_enabled),
|
||||
// security_refresh_token_reuse_interval -> refresh_token_reuse_interval (renamed)
|
||||
refresh_token_reuse_interval: asNumber(auth.security_refresh_token_reuse_interval),
|
||||
minimum_password_length: asNumber(auth.password_min_length),
|
||||
password_requirements: normalizePasswordRequirements(auth.password_required_characters),
|
||||
enable_anonymous_sign_ins: asBoolean(auth.external_anonymous_users_enabled),
|
||||
rate_limit: convertAuthRateLimit(auth),
|
||||
email: convertAuthEmail(auth),
|
||||
sms: convertAuthSms(auth),
|
||||
mfa: convertAuthMfa(auth),
|
||||
external: convertAuthExternalProviders(auth),
|
||||
// external_web3_solana_enabled -> web3.solana.enabled; external_web3_ethereum_enabled has no
|
||||
// config.toml field (schema only models solana)
|
||||
web3: { solana: { enabled: asBoolean(auth.external_web3_solana_enabled) } },
|
||||
oauth_server: {
|
||||
enabled: asBoolean(auth.oauth_server_enabled),
|
||||
allow_dynamic_registration: asBoolean(auth.oauth_server_allow_dynamic_registration),
|
||||
// oauth_server_authorization_path -> authorization_url_path (renamed)
|
||||
authorization_url_path: asString(auth.oauth_server_authorization_path),
|
||||
},
|
||||
// api_max_request_duration, db_max_pool_size(_unit), security_update_password_require_*,
|
||||
// audit_log_disable_postgres, sessions_*, hook_*_enabled/uri (secrets are always excluded
|
||||
// regardless), nimbus_oauth_client_id, mailer_allow_unverified_email_sign_ins,
|
||||
// mailer_notifications_*, password_hibp_enabled, saml_*, security_sb_forwarded_for_enabled,
|
||||
// security_captcha_*, sms_test_otp(_valid_until), index_worker_ensure_user_search_indexes_exist,
|
||||
// custom_oauth_enabled/max_providers, mailer_subjects_custom_contents,
|
||||
// mailer_templates_custom_contents: none of these have a gitHubConfigTomlSchema field.
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePasswordRequirements(value: unknown): string | undefined {
|
||||
if (value === null || value === 'NO_REQUIRED_CHARS') return ''
|
||||
return asString(value)
|
||||
}
|
||||
|
||||
function convertAuthRateLimit(
|
||||
auth: Record<string, unknown>
|
||||
): NonNullable<GitHubConfigToml['auth']>['rate_limit'] {
|
||||
return {
|
||||
email_sent: asNumber(auth.rate_limit_email_sent),
|
||||
sms_sent: asNumber(auth.rate_limit_sms_sent),
|
||||
anonymous_users: asNumber(auth.rate_limit_anonymous_users),
|
||||
token_refresh: asNumber(auth.rate_limit_token_refresh),
|
||||
web3: asNumber(auth.rate_limit_web3),
|
||||
// Confirmed against the Auth service source (supabase/auth#2090): the dashboard's
|
||||
// RATE_LIMIT_VERIFY backs "rate limit for token verifications" and RATE_LIMIT_OTP backs
|
||||
// "rate limit for sign ups and sign ins", despite the naming mismatch.
|
||||
token_verifications: asNumber(auth.rate_limit_verify),
|
||||
sign_in_sign_ups: asNumber(auth.rate_limit_otp),
|
||||
}
|
||||
}
|
||||
|
||||
function convertAuthEmail(
|
||||
auth: Record<string, unknown>
|
||||
): NonNullable<GitHubConfigToml['auth']>['email'] {
|
||||
return {
|
||||
enable_signup: asBoolean(auth.external_email_enabled),
|
||||
// mailer_autoconfirm -> enable_confirmations (inverted boolean)
|
||||
enable_confirmations: invertBoolean(auth.mailer_autoconfirm),
|
||||
double_confirm_changes: asBoolean(auth.mailer_secure_email_change_enabled),
|
||||
otp_length: asNumber(auth.mailer_otp_length),
|
||||
otp_expiry: asNumber(auth.mailer_otp_exp),
|
||||
// smtp_max_frequency is seconds on the dashboard vs. a duration string ("1m") in config.toml —
|
||||
// needs a number -> duration-string conversion, not done here. smtp_pass is a secret, excluded.
|
||||
// template subjects/content: dashboard stores literal subject/HTML, config.toml stores
|
||||
// `{ subject, content_path }` where content_path is a file path — not convertible without
|
||||
// writing the HTML to disk.
|
||||
}
|
||||
}
|
||||
|
||||
function convertAuthSms(
|
||||
auth: Record<string, unknown>
|
||||
): NonNullable<GitHubConfigToml['auth']>['sms'] {
|
||||
return {
|
||||
provider: asString(auth.sms_provider),
|
||||
// sms_autoconfirm -> enable_confirmations (inverted boolean)
|
||||
enable_confirmations: invertBoolean(auth.sms_autoconfirm),
|
||||
otp_expiry: asNumber(auth.sms_otp_exp),
|
||||
otp_length: asNumber(auth.sms_otp_length),
|
||||
template: asString(auth.sms_template),
|
||||
// sms_max_frequency is seconds on the dashboard vs. a duration string in config.toml — needs
|
||||
// conversion, not done here.
|
||||
twilio: {
|
||||
account_sid: asStringOrNull(auth.sms_twilio_account_sid),
|
||||
message_service_sid: asStringOrNull(auth.sms_twilio_message_service_sid),
|
||||
content_sid: asStringOrNull(auth.sms_twilio_content_sid),
|
||||
// auth_token is a secret, excluded
|
||||
},
|
||||
twilio_verify: {
|
||||
account_sid: asStringOrNull(auth.sms_twilio_verify_account_sid),
|
||||
message_service_sid: asStringOrNull(auth.sms_twilio_verify_message_service_sid),
|
||||
// auth_token is a secret, excluded
|
||||
},
|
||||
messagebird: { originator: asStringOrNull(auth.sms_messagebird_originator) },
|
||||
textlocal: { sender: asStringOrNull(auth.sms_textlocal_sender) },
|
||||
vonage: { from: asStringOrNull(auth.sms_vonage_from) },
|
||||
}
|
||||
}
|
||||
|
||||
function convertAuthMfa(
|
||||
auth: Record<string, unknown>
|
||||
): NonNullable<GitHubConfigToml['auth']>['mfa'] {
|
||||
return {
|
||||
max_enrolled_factors: asNumber(auth.mfa_max_enrolled_factors),
|
||||
totp: {
|
||||
enroll_enabled: asBoolean(auth.mfa_totp_enroll_enabled),
|
||||
verify_enabled: asBoolean(auth.mfa_totp_verify_enabled),
|
||||
},
|
||||
phone: {
|
||||
enroll_enabled: asBoolean(auth.mfa_phone_enroll_enabled),
|
||||
verify_enabled: asBoolean(auth.mfa_phone_verify_enabled),
|
||||
otp_length: asNumber(auth.mfa_phone_otp_length),
|
||||
template: asString(auth.mfa_phone_template),
|
||||
// mfa_phone_max_frequency is seconds on the dashboard vs. a duration string in config.toml —
|
||||
// needs conversion, not done here.
|
||||
},
|
||||
// mfa_allow_low_aal, mfa_web_authn_enroll_enabled, mfa_web_authn_verify_enabled,
|
||||
// passkey_enabled, webauthn_rp_*: no config.toml field.
|
||||
}
|
||||
}
|
||||
|
||||
function convertAuthExternalProviders(
|
||||
auth: Record<string, unknown>
|
||||
): NonNullable<GitHubConfigToml['auth']>['external'] {
|
||||
const providers: Record<string, Record<string, unknown>> = {}
|
||||
|
||||
for (const provider of EXTERNAL_AUTH_PROVIDERS) {
|
||||
providers[provider] = {
|
||||
enabled: asBoolean(auth[`external_${provider}_enabled`]),
|
||||
client_id: asStringOrNull(auth[`external_${provider}_client_id`]),
|
||||
email_optional: asBoolean(auth[`external_${provider}_email_optional`]),
|
||||
skip_nonce_check: asBoolean(auth[`external_${provider}_skip_nonce_check`]),
|
||||
...(EXTERNAL_AUTH_PROVIDERS_WITH_URL.has(provider)
|
||||
? { url: asStringOrNull(auth[`external_${provider}_url`]) }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
// external_apple_additional_client_ids, external_google_additional_client_ids,
|
||||
// external_google_skip_nonce_check: dashboard-only extensions with no config.toml field.
|
||||
|
||||
return providers
|
||||
}
|
||||
|
||||
function convertStorage(value: unknown): GitHubConfigToml['storage'] {
|
||||
const storage = asRecord(value)
|
||||
if (!storage) return undefined
|
||||
|
||||
const features = asRecord(storage.features)
|
||||
const s3Protocol = asRecord(features?.s3_protocol)
|
||||
// features.iceberg_catalog -> analytics (renamed)
|
||||
const icebergCatalog = asRecord(features?.iceberg_catalog)
|
||||
// features.vector_buckets -> vector (renamed)
|
||||
const vectorBuckets = asRecord(features?.vector_buckets)
|
||||
|
||||
return {
|
||||
file_size_limit: asNumber(storage.file_size_limit),
|
||||
s3_protocol: { enabled: asBoolean(s3Protocol?.enabled) },
|
||||
analytics: {
|
||||
enabled: asBoolean(icebergCatalog?.enabled),
|
||||
max_namespaces: asNumber(icebergCatalog?.max_namespaces),
|
||||
max_tables: asNumber(icebergCatalog?.max_tables),
|
||||
max_catalogs: asNumber(icebergCatalog?.max_catalogs),
|
||||
},
|
||||
vector: {
|
||||
enabled: asBoolean(vectorBuckets?.enabled),
|
||||
max_buckets: asNumber(vectorBuckets?.max_buckets),
|
||||
max_indexes: asNumber(vectorBuckets?.max_indexes),
|
||||
},
|
||||
// features.image_transformation.enabled, features.purge_cache.enabled: no config.toml field.
|
||||
// capabilities.list_v2, capabilities.iceberg_catalog: read-only capability flags, not settings.
|
||||
// upstream_target, migration_version: internal infra bookkeeping.
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
function asStringOrNull(value: unknown): string | null | undefined {
|
||||
return value === null ? null : asString(value)
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' ? value : undefined
|
||||
}
|
||||
|
||||
function asBoolean(value: unknown): boolean | undefined {
|
||||
return typeof value === 'boolean' ? value : undefined
|
||||
}
|
||||
|
||||
function invertBoolean(value: unknown): boolean | undefined {
|
||||
const bool = asBoolean(value)
|
||||
return bool === undefined ? undefined : !bool
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlike the other comma-separated lists (`api.schemas`, `api.extra_search_path`) whose order is
|
||||
* meaningful, a redirect allow-list is a set. Dedupe and sort it here so a list that differs from
|
||||
* config.toml only in ordering isn't reported as drift — `getConfigFieldState` compares with
|
||||
* `JSON.stringify`, which is order-sensitive.
|
||||
*/
|
||||
function convertRedirectUrls(value: unknown): string[] | undefined {
|
||||
const urls = splitCommaList(value)
|
||||
if (urls === undefined) return undefined
|
||||
|
||||
return normalizeRedirectUrls(urls)
|
||||
}
|
||||
|
||||
function splitCommaList(value: unknown): string[] | undefined {
|
||||
const raw = asString(value)
|
||||
if (raw === undefined) return undefined
|
||||
if (raw.trim() === '') return []
|
||||
return raw
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
@@ -1,24 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getConfigDriftSummary } from './github-config-drift'
|
||||
import {
|
||||
formatGitHubConfigDecodeMessage,
|
||||
getConfigDriftSummary,
|
||||
type ConfigDriftResult,
|
||||
type GitHubConfigDriftSummary,
|
||||
} from './github-config-drift'
|
||||
|
||||
/**
|
||||
* Asserts `result` is the `success` branch of `ConfigDriftResult` and returns its summary, so the
|
||||
* existing drift-comparison tests below can keep asserting on the summary shape directly.
|
||||
*/
|
||||
function summaryOf(result: ConfigDriftResult): GitHubConfigDriftSummary {
|
||||
if (result.status !== 'success') {
|
||||
throw new Error(`Expected a successful decode, got ${result.status}`)
|
||||
}
|
||||
return result.summary
|
||||
}
|
||||
|
||||
describe('getConfigDriftSummary', () => {
|
||||
it('counts a matching field as managed', () => {
|
||||
const summary = getConfigDriftSummary({
|
||||
dashboardConfig: { api: { max_rows: 1000 } },
|
||||
githubConfig: { api: { max_rows: 1000 } },
|
||||
})
|
||||
const summary = summaryOf(
|
||||
getConfigDriftSummary({
|
||||
dashboardConfig: { api: { max_rows: 1000 } },
|
||||
githubConfig: { api: { max_rows: 1000 } },
|
||||
})
|
||||
)
|
||||
|
||||
expect(summary).toEqual({ managedCount: 1, driftedFields: [], unmanagedFields: [] })
|
||||
expect(summary).toEqual({
|
||||
driftedFields: [],
|
||||
matchedFields: [{ section: 'api', configPath: 'api.max_rows', value: 1000 }],
|
||||
unmanagedFields: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a differing field as drifted, keeping raw (non-normalized) display values', () => {
|
||||
const summary = getConfigDriftSummary({
|
||||
dashboardConfig: { auth: { enable_signup: false } },
|
||||
githubConfig: { auth: { enable_signup: true } },
|
||||
})
|
||||
const summary = summaryOf(
|
||||
getConfigDriftSummary({
|
||||
dashboardConfig: { auth: { enable_signup: false } },
|
||||
githubConfig: { auth: { enable_signup: true } },
|
||||
})
|
||||
)
|
||||
|
||||
expect(summary.managedCount).toBe(0)
|
||||
expect(summary.matchedFields).toEqual([])
|
||||
expect(summary.driftedFields).toEqual([
|
||||
{
|
||||
section: 'auth',
|
||||
@@ -30,89 +54,93 @@ describe('getConfigDriftSummary', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('excludes a secret field even when dashboard and config.toml values match', () => {
|
||||
const summary = getConfigDriftSummary({
|
||||
dashboardConfig: { auth: { jwt_secret: 'shh' } },
|
||||
githubConfig: { auth: { jwt_secret: 'shh' } },
|
||||
})
|
||||
|
||||
expect(summary).toEqual({ managedCount: 0, driftedFields: [], unmanagedFields: [] })
|
||||
})
|
||||
|
||||
it('reports a field absent from config.toml as unmanaged', () => {
|
||||
const summary = getConfigDriftSummary({
|
||||
dashboardConfig: { auth: { site_url: 'https://example.com' } },
|
||||
githubConfig: { auth: {} },
|
||||
})
|
||||
|
||||
expect(summary).toEqual({
|
||||
managedCount: 0,
|
||||
driftedFields: [],
|
||||
unmanagedFields: [
|
||||
{ section: 'auth', configPath: 'auth.site_url', dashboardValue: 'https://example.com' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the hosted default when config.toml is code-owned and silent on the field', () => {
|
||||
const atDefault = getConfigDriftSummary({
|
||||
dashboardConfig: { auth: { site_url: 'http://localhost:3000' } },
|
||||
githubConfig: { auth: {}, config_source: 'code' },
|
||||
})
|
||||
expect(atDefault).toEqual({
|
||||
managedCount: 0,
|
||||
driftedFields: [],
|
||||
unmanagedFields: [
|
||||
{ section: 'auth', configPath: 'auth.site_url', dashboardValue: 'http://localhost:3000' },
|
||||
],
|
||||
})
|
||||
|
||||
const drifted = getConfigDriftSummary({
|
||||
dashboardConfig: { auth: { site_url: 'https://example.com' } },
|
||||
githubConfig: { auth: {}, config_source: 'code' },
|
||||
})
|
||||
it('reports a field missing from config.toml as drifted against the hosted value', () => {
|
||||
const drifted = summaryOf(
|
||||
getConfigDriftSummary({
|
||||
dashboardConfig: { auth: { site_url: 'https://example.com' } },
|
||||
githubConfig: { auth: {} },
|
||||
})
|
||||
)
|
||||
expect(drifted.driftedFields).toHaveLength(1)
|
||||
expect(drifted.driftedFields[0].githubValue).toBe('http://localhost:3000')
|
||||
// site_url defaults to http://127.0.0.1:3000
|
||||
expect(drifted.driftedFields[0].githubValue).toEqual('http://127.0.0.1:3000')
|
||||
})
|
||||
|
||||
describe('auth.additional_redirect_urls', () => {
|
||||
it('counts an identical list as managed', () => {
|
||||
const summary = getConfigDriftSummary({
|
||||
dashboardConfig: { auth: { additional_redirect_urls: ['https://a.com', 'https://b.com'] } },
|
||||
githubConfig: { auth: { additional_redirect_urls: ['https://a.com', 'https://b.com'] } },
|
||||
})
|
||||
const summary = summaryOf(
|
||||
getConfigDriftSummary({
|
||||
dashboardConfig: {
|
||||
auth: { additional_redirect_urls: ['https://a.com', 'https://b.com'] },
|
||||
},
|
||||
githubConfig: { auth: { additional_redirect_urls: ['https://a.com', 'https://b.com'] } },
|
||||
})
|
||||
)
|
||||
|
||||
expect(summary).toEqual({ managedCount: 1, driftedFields: [], unmanagedFields: [] })
|
||||
expect(summary).toEqual({
|
||||
driftedFields: [],
|
||||
matchedFields: [
|
||||
{
|
||||
section: 'auth',
|
||||
configPath: 'auth.additional_redirect_urls',
|
||||
value: ['https://a.com', 'https://b.com'],
|
||||
},
|
||||
],
|
||||
unmanagedFields: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('counts a list that differs only in order as managed', () => {
|
||||
const summary = getConfigDriftSummary({
|
||||
// `convertProjectConfigToGitHubConfig` sorts the dashboard list and `normalizeGithubValue`
|
||||
// sorts the config.toml one, so ordering can never register as drift.
|
||||
dashboardConfig: { auth: { additional_redirect_urls: ['https://a.com', 'https://b.com'] } },
|
||||
githubConfig: { auth: { additional_redirect_urls: ['https://b.com', 'https://a.com'] } },
|
||||
})
|
||||
const summary = summaryOf(
|
||||
getConfigDriftSummary({
|
||||
// `fromApiProjectConfig` sorts the dashboard list and `normalizeGithubValue` sorts the
|
||||
// config.toml one, so ordering can never register as drift.
|
||||
dashboardConfig: {
|
||||
auth: { additional_redirect_urls: ['https://a.com', 'https://b.com'] },
|
||||
},
|
||||
githubConfig: { auth: { additional_redirect_urls: ['https://b.com', 'https://a.com'] } },
|
||||
})
|
||||
)
|
||||
|
||||
expect(summary).toEqual({ managedCount: 1, driftedFields: [], unmanagedFields: [] })
|
||||
expect(summary).toEqual({
|
||||
driftedFields: [],
|
||||
matchedFields: [
|
||||
{
|
||||
section: 'auth',
|
||||
configPath: 'auth.additional_redirect_urls',
|
||||
value: ['https://a.com', 'https://b.com'],
|
||||
},
|
||||
],
|
||||
unmanagedFields: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores duplicate and untrimmed entries in config.toml', () => {
|
||||
const summary = getConfigDriftSummary({
|
||||
dashboardConfig: { auth: { additional_redirect_urls: ['https://a.com'] } },
|
||||
githubConfig: {
|
||||
auth: { additional_redirect_urls: [' https://a.com ', 'https://a.com', ''] },
|
||||
},
|
||||
})
|
||||
// `auth.additional_redirect_urls` is a registry "set"-equality field whose document-side
|
||||
// canonicalization re-joins-and-splits the array (mirroring a push/pull round trip), which
|
||||
// trims each entry — so padding and exact-duplicate entries never register as drift.
|
||||
const summary = summaryOf(
|
||||
getConfigDriftSummary({
|
||||
dashboardConfig: { auth: { additional_redirect_urls: ['https://a.com'] } },
|
||||
githubConfig: {
|
||||
auth: { additional_redirect_urls: [' https://a.com ', 'https://a.com'] },
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
expect(summary.managedCount).toBe(1)
|
||||
expect(summary.matchedFields).toHaveLength(1)
|
||||
expect(summary.driftedFields).toEqual([])
|
||||
})
|
||||
|
||||
it('reports a list the dashboard adds to as drifted, keeping the raw dashboard list', () => {
|
||||
const summary = getConfigDriftSummary({
|
||||
dashboardConfig: { auth: { additional_redirect_urls: ['https://a.com', 'https://b.com'] } },
|
||||
githubConfig: { auth: { additional_redirect_urls: ['https://a.com'] } },
|
||||
})
|
||||
const summary = summaryOf(
|
||||
getConfigDriftSummary({
|
||||
dashboardConfig: {
|
||||
auth: { additional_redirect_urls: ['https://a.com', 'https://b.com'] },
|
||||
},
|
||||
githubConfig: { auth: { additional_redirect_urls: ['https://a.com'] } },
|
||||
})
|
||||
)
|
||||
|
||||
expect(summary.driftedFields).toEqual([
|
||||
{
|
||||
@@ -125,20 +153,107 @@ describe('getConfigDriftSummary', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('compares against the empty hosted default when code-owned config.toml is silent', () => {
|
||||
const atDefault = getConfigDriftSummary({
|
||||
dashboardConfig: { auth: { additional_redirect_urls: [] } },
|
||||
githubConfig: { auth: {}, config_source: 'code' },
|
||||
})
|
||||
expect(atDefault.managedCount).toBe(0)
|
||||
expect(atDefault.driftedFields).toEqual([])
|
||||
|
||||
const drifted = getConfigDriftSummary({
|
||||
dashboardConfig: { auth: { additional_redirect_urls: ['https://a.com'] } },
|
||||
githubConfig: { auth: {}, config_source: 'code' },
|
||||
})
|
||||
it('reports a list missing from config.toml as drifted', () => {
|
||||
const drifted = summaryOf(
|
||||
getConfigDriftSummary({
|
||||
dashboardConfig: { auth: { additional_redirect_urls: ['https://a.com'] } },
|
||||
githubConfig: { auth: {} },
|
||||
})
|
||||
)
|
||||
expect(drifted.driftedFields).toHaveLength(1)
|
||||
expect(drifted.driftedFields[0].githubValue).toEqual([])
|
||||
// additional_redirect_urls defaults to ['https://127.0.0.1:3000']
|
||||
expect(drifted.driftedFields[0].githubValue).toEqual(['https://127.0.0.1:3000'])
|
||||
expect(drifted.driftedFields[0].dashboardValue).toEqual(['https://a.com'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalid config.toml', () => {
|
||||
it('reports a wrong scalar type with its dotted path', () => {
|
||||
const result = getConfigDriftSummary({
|
||||
dashboardConfig: { api: { max_rows: 1000 } },
|
||||
githubConfig: { api: { max_rows: 'abc' } },
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'invalid-config',
|
||||
issues: [{ path: 'api.max_rows', message: 'Expected number' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('reports an explicit null the same way as a wrong scalar type', () => {
|
||||
const result = getConfigDriftSummary({
|
||||
dashboardConfig: { api: { max_rows: 1000 } },
|
||||
githubConfig: { api: { max_rows: null } },
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'invalid-config',
|
||||
issues: [{ path: 'api.max_rows', message: 'Expected number' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a nested path', () => {
|
||||
const result = getConfigDriftSummary({
|
||||
dashboardConfig: { auth: { enable_signup: true } },
|
||||
githubConfig: { auth: { external: { github: { enabled: 'yes' } } } },
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'invalid-config',
|
||||
issues: [{ path: 'auth.external.github.enabled', message: 'Expected boolean' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('reports every bad field in one pass', () => {
|
||||
const result = getConfigDriftSummary({
|
||||
dashboardConfig: { api: { max_rows: 1000 } },
|
||||
githubConfig: { api: { max_rows: 'abc', port: 'x' } },
|
||||
})
|
||||
|
||||
expect(result.status).toEqual('invalid-config')
|
||||
expect(result).toMatchObject({
|
||||
issues: expect.arrayContaining([
|
||||
{ path: 'api.max_rows', message: 'Expected number' },
|
||||
{ path: 'api.port', message: 'Expected number' },
|
||||
]),
|
||||
})
|
||||
if (result.status === 'invalid-config') {
|
||||
expect(result.issues).toHaveLength(2)
|
||||
}
|
||||
})
|
||||
|
||||
it('still decodes successfully when the document has unknown sections or keys', () => {
|
||||
const summary = summaryOf(
|
||||
getConfigDriftSummary({
|
||||
dashboardConfig: { api: { max_rows: 1000 } },
|
||||
githubConfig: { api: { max_rows: 1000, made_up_key: true }, made_up_section: {} },
|
||||
})
|
||||
)
|
||||
|
||||
expect(summary.matchedFields).toEqual([
|
||||
{ section: 'api', configPath: 'api.max_rows', value: 1000 },
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatGitHubConfigDecodeMessage', () => {
|
||||
it('formats a single issue as one sentence', () => {
|
||||
const message = formatGitHubConfigDecodeMessage([
|
||||
{ path: 'api.max_rows', message: 'Expected number' },
|
||||
])
|
||||
|
||||
expect(message).toEqual('config.toml has an invalid value at api.max_rows: expected number.')
|
||||
})
|
||||
|
||||
it('formats multiple issues as a comma-separated list', () => {
|
||||
const message = formatGitHubConfigDecodeMessage([
|
||||
{ path: 'api.max_rows', message: 'Expected number' },
|
||||
{ path: 'api.port', message: 'Expected number' },
|
||||
])
|
||||
|
||||
expect(message).toEqual(
|
||||
'config.toml has invalid values: api.max_rows (expected number), api.port (expected number).'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,23 +1,21 @@
|
||||
import {
|
||||
CliConfigSchema,
|
||||
diffProjectConfig,
|
||||
fromApiProjectConfig,
|
||||
type CliConfig,
|
||||
type ConfigChange,
|
||||
type ProjectConfig,
|
||||
} from '@supabase/config'
|
||||
import { Result, Schema, SchemaIssue } from 'effect'
|
||||
import { isPlainObject, lowerFirst } from 'lodash'
|
||||
|
||||
import {
|
||||
CONFIG_SECTIONS,
|
||||
getConfigValue,
|
||||
getFieldDefinition,
|
||||
getSectionFieldEntries,
|
||||
isSecretConfigField,
|
||||
toProjectHomepageHref,
|
||||
type ConfigSection,
|
||||
} from './github-config-field-registry'
|
||||
import { gitHubConfigTomlSchema, type GitHubConfigToml } from './github-config.types'
|
||||
|
||||
// This file should be temporary and will be removed once we publish a "@supabase/config" package.
|
||||
|
||||
type GitHubConfigFieldStatus = 'unmanaged' | 'managed' | 'drifted'
|
||||
|
||||
interface GitHubConfigFieldState {
|
||||
status: GitHubConfigFieldStatus
|
||||
configPath?: string
|
||||
settingHref?: (projectRef: string) => string
|
||||
githubValue?: unknown
|
||||
}
|
||||
} from './ConfigurationDriftPage.constants'
|
||||
|
||||
export interface GitHubConfigDriftField {
|
||||
section: ConfigSection
|
||||
@@ -33,109 +31,185 @@ export interface UnmanagedConfigField {
|
||||
dashboardValue: unknown
|
||||
}
|
||||
|
||||
interface GitHubConfigDriftSummary {
|
||||
managedCount: number
|
||||
export interface MatchedConfigField {
|
||||
section: ConfigSection
|
||||
configPath: string
|
||||
value: unknown
|
||||
}
|
||||
|
||||
export interface GitHubConfigDriftSummary {
|
||||
driftedFields: GitHubConfigDriftField[]
|
||||
matchedFields: MatchedConfigField[]
|
||||
unmanagedFields: UnmanagedConfigField[]
|
||||
}
|
||||
|
||||
function getConfigFieldState({
|
||||
configPath,
|
||||
dashboardConfig,
|
||||
githubConfig,
|
||||
}: {
|
||||
configPath: string
|
||||
dashboardConfig: GitHubConfigToml
|
||||
githubConfig?: GitHubConfigToml
|
||||
}): GitHubConfigFieldState {
|
||||
const definition = getFieldDefinition(configPath)
|
||||
if (!definition || !githubConfig || isSecretConfigField(definition.configPath)) {
|
||||
return { status: 'unmanaged' }
|
||||
}
|
||||
|
||||
const normalizedDashboardValue = getConfigValue(dashboardConfig, definition.configPath)
|
||||
|
||||
let githubValue = getConfigValue(githubConfig, definition.configPath)
|
||||
if (githubValue === undefined) {
|
||||
const isCodeOwned = getConfigValue(githubConfig, 'config_source') === 'code'
|
||||
if (!isCodeOwned || definition.hostedDefault === undefined) return { status: 'unmanaged' }
|
||||
|
||||
const normalizedDefaultValue =
|
||||
definition.normalizeGithubValue?.(definition.hostedDefault) ?? definition.hostedDefault
|
||||
if (valuesMatch(normalizedDashboardValue, normalizedDefaultValue)) {
|
||||
return { status: 'unmanaged' }
|
||||
}
|
||||
|
||||
githubValue = definition.hostedDefault
|
||||
}
|
||||
|
||||
const normalizedGithubValue = definition.normalizeGithubValue?.(githubValue) ?? githubValue
|
||||
return {
|
||||
status: valuesMatch(normalizedDashboardValue, normalizedGithubValue) ? 'managed' : 'drifted',
|
||||
configPath: definition.configPath,
|
||||
settingHref: definition.settingHref,
|
||||
githubValue,
|
||||
}
|
||||
export interface GitHubConfigDecodeIssue {
|
||||
path: string // e.g. 'api.max_rows'
|
||||
message: string
|
||||
}
|
||||
|
||||
export type GitHubConfigDecodeResult =
|
||||
| { status: 'success'; config: CliConfig; document: Record<string, unknown> }
|
||||
| { status: 'invalid'; issues: GitHubConfigDecodeIssue[] }
|
||||
|
||||
const EMPTY_SUMMARY: GitHubConfigDriftSummary = {
|
||||
driftedFields: [],
|
||||
matchedFields: [],
|
||||
unmanagedFields: [],
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a v2 project-config API response's `attributes` into the hosted-section shape both
|
||||
* sides of a drift comparison are normalized to. Returns `undefined` when `attributes` isn't
|
||||
* loaded yet; throws if the API returned something @supabase/config can't map, so callers can
|
||||
* surface it as an error rather than silently reporting no drift.
|
||||
*/
|
||||
export function fromDashboardProjectConfig(attributes: unknown): ProjectConfig | undefined {
|
||||
if (attributes === undefined) return undefined
|
||||
return fromApiProjectConfig(attributes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a parsed config.toml document into the `{ config, document }` pair `diffProjectConfig`
|
||||
* takes as its local operand. Keeping the raw `document` alongside the decoded `config` is what
|
||||
* unlocks raw-presence masking (distinguishing "the file wrote this value" from "the file inherited
|
||||
* a schema default") — see `DiffProjectConfigOptions.local`'s own docstring. Returns `undefined`
|
||||
* when `document` isn't loaded yet; returns `{ status: 'invalid', issues }` when it fails to decode
|
||||
* against the schema, so callers can surface the offending path(s) rather than silently reporting
|
||||
* no drift.
|
||||
*/
|
||||
export function decodeGithubConfigDocument(
|
||||
document: unknown
|
||||
): GitHubConfigDecodeResult | undefined {
|
||||
if (!isRecord(document)) return undefined
|
||||
|
||||
const result = Schema.decodeUnknownResult(CliConfigSchema, { errors: 'all' })(document)
|
||||
if (Result.isFailure(result)) {
|
||||
const formatter = SchemaIssue.makeFormatterStandardSchemaV1()
|
||||
const issues = formatter(result.failure.issue).issues.map((issue) => ({
|
||||
path: (issue.path ?? []).map(String).join('.'),
|
||||
message: issue.message,
|
||||
}))
|
||||
return { status: 'invalid', issues }
|
||||
}
|
||||
|
||||
return { status: 'success', config: result.success, document }
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return isPlainObject(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `configPath` (dotted, e.g. `api.max_rows`) is declared anywhere in the raw config.toml
|
||||
* document — walking the parsed document itself rather than the decoded config, since decoding
|
||||
* fills in schema defaults for every field the file never mentioned. This is the only reliable way
|
||||
* to tell "config.toml set this" apart from "config.toml is silent and this happens to equal the
|
||||
* default" — a distinction `diffProjectConfig`'s change classification collapses when the two
|
||||
* sides' values coincide.
|
||||
*/
|
||||
function isPathDeclaredInDocument(
|
||||
document: Record<string, unknown> | undefined,
|
||||
configPath: string
|
||||
): boolean {
|
||||
let current: unknown = document
|
||||
for (const segment of configPath.split('.')) {
|
||||
if (!isRecord(current) || !(segment in current)) return false
|
||||
current = current[segment]
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export type ConfigDriftResult =
|
||||
| { status: 'success'; summary: GitHubConfigDriftSummary }
|
||||
| { status: 'invalid-config'; issues: GitHubConfigDecodeIssue[] }
|
||||
|
||||
/**
|
||||
* Returns `{ status: 'invalid-config', issues }` if `githubConfig` fails to decode against the
|
||||
* schema (see `decodeGithubConfigDocument`), so callers can surface the offending path(s) instead
|
||||
* of silently reporting no drift.
|
||||
*/
|
||||
export function getConfigDriftSummary({
|
||||
dashboardConfig,
|
||||
githubConfig,
|
||||
}: {
|
||||
dashboardConfig?: GitHubConfigToml
|
||||
githubConfig?: GitHubConfigToml
|
||||
}): GitHubConfigDriftSummary {
|
||||
dashboardConfig?: ProjectConfig
|
||||
githubConfig?: Record<string, unknown>
|
||||
}): ConfigDriftResult {
|
||||
if (!dashboardConfig || !githubConfig) {
|
||||
return { managedCount: 0, driftedFields: [], unmanagedFields: [] }
|
||||
return { status: 'success', summary: EMPTY_SUMMARY }
|
||||
}
|
||||
|
||||
const githubConfigResult = gitHubConfigTomlSchema.safeParse(githubConfig)
|
||||
const dashboardConfigResult = gitHubConfigTomlSchema.safeParse(dashboardConfig)
|
||||
if (!githubConfigResult.success || !dashboardConfigResult.success) {
|
||||
return { managedCount: 0, driftedFields: [], unmanagedFields: [] }
|
||||
const decodedGithubConfig = decodeGithubConfigDocument(githubConfig)
|
||||
if (!decodedGithubConfig) {
|
||||
return { status: 'success', summary: EMPTY_SUMMARY }
|
||||
}
|
||||
if (decodedGithubConfig.status === 'invalid') {
|
||||
return { status: 'invalid-config', issues: decodedGithubConfig.issues }
|
||||
}
|
||||
const cleanedGithubConfig = githubConfigResult.data
|
||||
const cleanedDashboardConfig = dashboardConfigResult.data
|
||||
|
||||
let managedCount = 0
|
||||
const changeSet = diffProjectConfig({
|
||||
local: { config: decodedGithubConfig.config, document: decodedGithubConfig.document },
|
||||
remote: dashboardConfig,
|
||||
})
|
||||
|
||||
const changesByPath = new Map<string, ConfigChange>(
|
||||
changeSet.changes.map((change) => [change.path.join('.'), change])
|
||||
)
|
||||
const maskedPaths = new Set(changeSet.masked.map((path) => path.join('.')))
|
||||
const unmanagedByPushPaths = new Set(changeSet.unmanaged.map((path) => path.join('.')))
|
||||
|
||||
const driftedFields: GitHubConfigDriftField[] = []
|
||||
const matchedFields: MatchedConfigField[] = []
|
||||
const unmanagedFields: UnmanagedConfigField[] = []
|
||||
|
||||
for (const section of CONFIG_SECTIONS) {
|
||||
const sectionConfig = cleanedDashboardConfig[section]
|
||||
const sectionConfig = dashboardConfig[section]
|
||||
if (!sectionConfig) continue
|
||||
|
||||
for (const { configPath, rawValue } of getSectionFieldEntries(section, sectionConfig)) {
|
||||
if (isSecretConfigField(configPath)) continue
|
||||
if (maskedPaths.has(configPath)) continue
|
||||
|
||||
const state = getConfigFieldState({
|
||||
configPath,
|
||||
dashboardConfig: cleanedDashboardConfig,
|
||||
githubConfig: cleanedGithubConfig,
|
||||
})
|
||||
const change = changesByPath.get(configPath)
|
||||
if (change) {
|
||||
const definition = getFieldDefinition(configPath)
|
||||
|
||||
if (state.status === 'managed') {
|
||||
managedCount += 1
|
||||
driftedFields.push({
|
||||
section,
|
||||
configPath,
|
||||
settingHref: definition?.settingHref ?? toProjectHomepageHref,
|
||||
dashboardValue: change.remote,
|
||||
githubValue: change.local,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (state.status === 'unmanaged' || !state.configPath || !state.settingHref) {
|
||||
|
||||
const isTrackedInConfigToml = isPathDeclaredInDocument(
|
||||
decodedGithubConfig.document,
|
||||
configPath
|
||||
)
|
||||
if (unmanagedByPushPaths.has(configPath) || !isTrackedInConfigToml) {
|
||||
unmanagedFields.push({ section, configPath, dashboardValue: rawValue })
|
||||
continue
|
||||
}
|
||||
|
||||
driftedFields.push({
|
||||
section,
|
||||
configPath: state.configPath,
|
||||
settingHref: state.settingHref,
|
||||
dashboardValue: rawValue,
|
||||
githubValue: state.githubValue,
|
||||
})
|
||||
matchedFields.push({ section, configPath, value: rawValue })
|
||||
}
|
||||
}
|
||||
|
||||
return { managedCount, driftedFields, unmanagedFields }
|
||||
return { status: 'success', summary: { driftedFields, matchedFields, unmanagedFields } }
|
||||
}
|
||||
|
||||
function valuesMatch(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right)
|
||||
/**
|
||||
* Turns `GitHubConfigDecodeIssue[]` into a single-sentence, user-facing message for `AlertError`,
|
||||
* which renders `error.message` in a `<p>` — Effect's own multi-line default reads badly there.
|
||||
*/
|
||||
export function formatGitHubConfigDecodeMessage(issues: GitHubConfigDecodeIssue[]): string {
|
||||
if (issues.length === 1) {
|
||||
const [issue] = issues
|
||||
return `config.toml has an invalid value at ${issue.path}: ${lowerFirst(issue.message)}.`
|
||||
}
|
||||
|
||||
const details = issues.map((issue) => `${issue.path} (${lowerFirst(issue.message)})`).join(', ')
|
||||
return `config.toml has invalid values: ${details}.`
|
||||
}
|
||||
@@ -1,555 +0,0 @@
|
||||
import { type GitHubConfigToml } from './github-config.types'
|
||||
import { StorageSizeUnits } from '@/components/interfaces/Storage/StorageSettings/StorageSettings.constants'
|
||||
import { convertToBytes } from '@/components/interfaces/Storage/StorageSettings/StorageSettings.utils'
|
||||
|
||||
// This file should be temporary and will be removed once we publish a "@supabase/config" package.
|
||||
|
||||
// Every top-level config.toml section, excluding `project_id` which is a scalar, not a section.
|
||||
export const CONFIG_SECTIONS = [
|
||||
'api',
|
||||
'auth',
|
||||
'db',
|
||||
'storage',
|
||||
'realtime',
|
||||
'studio',
|
||||
'inbucket',
|
||||
'functions',
|
||||
'edge_runtime',
|
||||
'analytics',
|
||||
'remotes',
|
||||
'experimental',
|
||||
] as const satisfies readonly Exclude<keyof GitHubConfigToml, 'project_id'>[]
|
||||
export type ConfigSection = (typeof CONFIG_SECTIONS)[number]
|
||||
|
||||
// The v2 project config API's own top-level section names — distinct from `CONFIG_SECTIONS`,
|
||||
// which mirrors config.toml's naming (e.g. `pooler` here nests under config.toml's `db.pooler`).
|
||||
const DASHBOARD_CONFIG_SECTIONS = [
|
||||
'api',
|
||||
'auth',
|
||||
'database',
|
||||
'pooler',
|
||||
'realtime',
|
||||
'storage',
|
||||
] as const
|
||||
type DashboardConfigSection = (typeof DASHBOARD_CONFIG_SECTIONS)[number]
|
||||
|
||||
export type ConfigDriftDashboardConfig = Partial<
|
||||
Record<DashboardConfigSection, Record<string, unknown>>
|
||||
>
|
||||
|
||||
interface ConfigFieldDefinition {
|
||||
settingHref: (projectRef: string) => string
|
||||
/** Value to compare against when the field is absent from a code-owned config.toml. */
|
||||
hostedDefault?: unknown
|
||||
normalizeGithubValue?: (value: unknown) => unknown
|
||||
}
|
||||
|
||||
export type ResolvedConfigFieldDefinition = ConfigFieldDefinition & { configPath: string }
|
||||
|
||||
// external_<provider>_enabled/client_id/email_optional/skip_nonce_check is the dashboard's flat
|
||||
// naming for every OAuth provider; a handful of self-hosted providers also expose `_url`.
|
||||
// external_<provider>_secret is always a secret and is never read here.
|
||||
export const EXTERNAL_AUTH_PROVIDERS = [
|
||||
'apple',
|
||||
'azure',
|
||||
'bitbucket',
|
||||
'discord',
|
||||
'facebook',
|
||||
'figma',
|
||||
'github',
|
||||
'gitlab',
|
||||
'google',
|
||||
'kakao',
|
||||
'keycloak',
|
||||
'linkedin_oidc',
|
||||
'notion',
|
||||
'slack',
|
||||
'slack_oidc',
|
||||
'spotify',
|
||||
'twitch',
|
||||
'twitter',
|
||||
'x',
|
||||
'workos',
|
||||
'zoom',
|
||||
] as const
|
||||
|
||||
export const EXTERNAL_AUTH_PROVIDERS_WITH_URL = new Set(['azure', 'gitlab', 'keycloak', 'workos'])
|
||||
|
||||
const toAuthUrlConfigHref = (projectRef: string) => `/project/${projectRef}/auth/url-configuration`
|
||||
const toAuthProvidersHref = (projectRef: string) => `/project/${projectRef}/auth/providers`
|
||||
const toApiSettingsHref = (projectRef: string) => `/project/${projectRef}/settings/api`
|
||||
const toStorageSettingsHref = (projectRef: string) =>
|
||||
`/project/${projectRef}/storage/files/settings`
|
||||
const toProjectHref = (projectRef: string) => `/project/${projectRef}`
|
||||
|
||||
/**
|
||||
* Every trackable field across every section, keyed by its config.toml dotted path — the same shape
|
||||
* `getConfigValue`/`setConfigValue` address, and the single identifier a field is known by once the
|
||||
* dashboard config has been run through `convertProjectConfigToGitHubConfig`.
|
||||
*/
|
||||
const CONFIG_FIELD_REGISTRY: Record<string, ConfigFieldDefinition> = {
|
||||
'auth.enable_signup': {
|
||||
settingHref: toAuthProvidersHref,
|
||||
},
|
||||
'auth.enable_anonymous_sign_ins': {
|
||||
settingHref: toAuthProvidersHref,
|
||||
},
|
||||
'auth.enable_manual_linking': {
|
||||
settingHref: toAuthProvidersHref,
|
||||
},
|
||||
'auth.site_url': {
|
||||
settingHref: toAuthUrlConfigHref,
|
||||
hostedDefault: 'http://localhost:3000',
|
||||
},
|
||||
'auth.additional_redirect_urls': {
|
||||
settingHref: toAuthUrlConfigHref,
|
||||
hostedDefault: [],
|
||||
normalizeGithubValue: normalizeRedirectUrls,
|
||||
},
|
||||
'auth.email.enable_signup': {
|
||||
settingHref: toAuthProvidersHref,
|
||||
},
|
||||
'auth.sms.enable_signup': {
|
||||
settingHref: toAuthProvidersHref,
|
||||
},
|
||||
'auth.email.enable_confirmations': {
|
||||
settingHref: toAuthProvidersHref,
|
||||
},
|
||||
'auth.email.double_confirm_changes': {
|
||||
settingHref: toAuthProvidersHref,
|
||||
},
|
||||
'auth.email.otp_length': {
|
||||
settingHref: toAuthProvidersHref,
|
||||
},
|
||||
'auth.email.otp_expiry': {
|
||||
settingHref: toAuthProvidersHref,
|
||||
},
|
||||
'auth.minimum_password_length': {
|
||||
settingHref: toAuthProvidersHref,
|
||||
},
|
||||
'auth.password_requirements': {
|
||||
settingHref: toAuthProvidersHref,
|
||||
},
|
||||
'auth.sms.provider': {
|
||||
settingHref: toAuthProvidersHref,
|
||||
},
|
||||
'auth.sms.enable_confirmations': {
|
||||
settingHref: toAuthProvidersHref,
|
||||
},
|
||||
'auth.sms.otp_expiry': {
|
||||
settingHref: toAuthProvidersHref,
|
||||
},
|
||||
'auth.sms.otp_length': {
|
||||
settingHref: toAuthProvidersHref,
|
||||
},
|
||||
'auth.sms.template': {
|
||||
settingHref: toAuthProvidersHref,
|
||||
},
|
||||
'auth.sms.twilio.account_sid': {
|
||||
settingHref: toAuthProvidersHref,
|
||||
},
|
||||
'auth.sms.twilio.message_service_sid': {
|
||||
settingHref: toAuthProvidersHref,
|
||||
},
|
||||
'auth.sms.twilio.content_sid': {
|
||||
settingHref: toAuthProvidersHref,
|
||||
},
|
||||
'auth.sms.twilio_verify.account_sid': {
|
||||
settingHref: toAuthProvidersHref,
|
||||
},
|
||||
'auth.sms.twilio_verify.message_service_sid': {
|
||||
settingHref: toAuthProvidersHref,
|
||||
},
|
||||
'auth.sms.messagebird.originator': {
|
||||
settingHref: toAuthProvidersHref,
|
||||
},
|
||||
'auth.sms.textlocal.sender': {
|
||||
settingHref: toAuthProvidersHref,
|
||||
},
|
||||
'auth.sms.vonage.from': {
|
||||
settingHref: toAuthProvidersHref,
|
||||
},
|
||||
'api.max_rows': {
|
||||
settingHref: toApiSettingsHref,
|
||||
},
|
||||
'storage.file_size_limit': {
|
||||
settingHref: toStorageSettingsHref,
|
||||
normalizeGithubValue: parseFileSizeToBytes,
|
||||
},
|
||||
'api.enabled': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'api.port': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'api.schemas': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'api.extra_search_path': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'api.tls.enabled': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'db.port': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'db.shadow_port': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'db.major_version': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'db.pooler.enabled': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'db.pooler.port': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'db.pooler.pool_mode': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'db.pooler.default_pool_size': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'db.pooler.max_client_conn': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'db.migrations.enabled': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'db.migrations.schema_paths': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'db.seed.enabled': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'db.seed.sql_paths': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'db.network_restrictions.enabled': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'db.network_restrictions.allowed_cidrs': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'db.network_restrictions.allowed_cidrs_v6': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'realtime.enabled': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'studio.enabled': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'studio.port': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'studio.api_url': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'inbucket.enabled': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'inbucket.port': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'storage.enabled': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'storage.s3_protocol.enabled': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'storage.analytics.enabled': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'storage.analytics.max_namespaces': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'storage.analytics.max_tables': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'storage.analytics.max_catalogs': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'storage.vector.enabled': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'storage.vector.max_buckets': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'storage.vector.max_indexes': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'auth.enabled': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'auth.jwt_expiry': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'auth.enable_refresh_token_rotation': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'auth.refresh_token_reuse_interval': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'auth.rate_limit.email_sent': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'auth.rate_limit.sms_sent': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'auth.rate_limit.anonymous_users': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'auth.rate_limit.token_refresh': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'auth.rate_limit.sign_in_sign_ups': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'auth.rate_limit.token_verifications': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'auth.rate_limit.web3': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'auth.email.secure_password_change': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'auth.email.max_frequency': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'auth.sms.max_frequency': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'auth.sms.twilio.enabled': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'auth.mfa.max_enrolled_factors': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'auth.mfa.totp.enroll_enabled': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'auth.mfa.totp.verify_enabled': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'auth.mfa.phone.enroll_enabled': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'auth.mfa.phone.verify_enabled': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'auth.mfa.phone.otp_length': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'auth.mfa.phone.template': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'auth.mfa.phone.max_frequency': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'auth.web3.solana.enabled': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'auth.oauth_server.enabled': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'auth.oauth_server.authorization_url_path': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'auth.oauth_server.allow_dynamic_registration': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'edge_runtime.enabled': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'edge_runtime.policy': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'edge_runtime.inspector_port': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'edge_runtime.deno_version': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'analytics.enabled': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'analytics.port': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'analytics.backend': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'experimental.orioledb_version': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'experimental.s3_host': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'experimental.s3_region': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'experimental.s3_access_key': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
'experimental.s3_secret_key': {
|
||||
settingHref: toProjectHref,
|
||||
},
|
||||
}
|
||||
|
||||
for (const provider of EXTERNAL_AUTH_PROVIDERS) {
|
||||
for (const field of ['enabled', 'client_id', 'email_optional', 'skip_nonce_check']) {
|
||||
CONFIG_FIELD_REGISTRY[`auth.external.${provider}.${field}`] = {
|
||||
settingHref: toAuthProvidersHref,
|
||||
}
|
||||
}
|
||||
if (EXTERNAL_AUTH_PROVIDERS_WITH_URL.has(provider)) {
|
||||
CONFIG_FIELD_REGISTRY[`auth.external.${provider}.url`] = { settingHref: toAuthProvidersHref }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* config.toml paths whose values are secrets — never tracked, compared, or displayed.
|
||||
* A `*` segment matches any key at that position (e.g. every vault entry, every OAuth provider).
|
||||
*/
|
||||
const SECRET_CONFIG_FIELDS = [
|
||||
'studio.openai_api_key',
|
||||
'db.root_key',
|
||||
'db.vault.*',
|
||||
'auth.publishable_key',
|
||||
'auth.secret_key',
|
||||
'auth.jwt_secret',
|
||||
'auth.email.smtp.pass',
|
||||
'auth.captcha.secret',
|
||||
'auth.hook.mfa_verification_attempt.secrets',
|
||||
'auth.hook.password_verification_attempt.secrets',
|
||||
'auth.hook.custom_access_token.secrets',
|
||||
'auth.hook.send_sms.secrets',
|
||||
'auth.hook.send_email.secrets',
|
||||
'auth.hook.before_user_created.secrets',
|
||||
'auth.sms.twilio.auth_token',
|
||||
'auth.sms.twilio_verify.auth_token',
|
||||
'auth.sms.messagebird.access_key',
|
||||
'auth.sms.textlocal.api_key',
|
||||
'auth.sms.vonage.api_secret',
|
||||
'auth.external.*.secret',
|
||||
'edge_runtime.secrets.*',
|
||||
'experimental.s3_access_key',
|
||||
'experimental.s3_secret_key',
|
||||
] as const
|
||||
|
||||
export function isSecretConfigField(configPath: string): boolean {
|
||||
const pathSegments = configPath.split('.')
|
||||
|
||||
return SECRET_CONFIG_FIELDS.some((secretPath) => {
|
||||
const secretSegments = secretPath.split('.')
|
||||
const isPrefixPattern = secretSegments[secretSegments.length - 1] === '*'
|
||||
if (isPrefixPattern) {
|
||||
if (pathSegments.length < secretSegments.length) return false
|
||||
} else if (secretSegments.length !== pathSegments.length) {
|
||||
return false
|
||||
}
|
||||
return secretSegments.every((segment, i) => segment === '*' || segment === pathSegments[i])
|
||||
})
|
||||
}
|
||||
|
||||
export function getFieldDefinition(configPath: string): ResolvedConfigFieldDefinition | undefined {
|
||||
const definition = CONFIG_FIELD_REGISTRY[configPath]
|
||||
if (!definition) return undefined
|
||||
return { ...definition, configPath }
|
||||
}
|
||||
|
||||
/**
|
||||
* A config section can nest fields arbitrarily deep (e.g. `storage.analytics.max_namespaces`),
|
||||
* mirroring how deep `gitHubConfigTomlSchema` itself nests. Recurse through plain objects — but not
|
||||
* arrays, which are leaf values — to produce one section-prefixed dotted path per leaf, matching how
|
||||
* `CONFIG_FIELD_REGISTRY` is keyed.
|
||||
*/
|
||||
export function getSectionFieldEntries(
|
||||
section: ConfigSection,
|
||||
sectionConfig: Record<string, unknown>
|
||||
): Array<{ configPath: string; rawValue: unknown }> {
|
||||
const entries: Array<{ configPath: string; rawValue: unknown }> = []
|
||||
|
||||
function walk(value: unknown, path: string[]) {
|
||||
if (isRecord(value)) {
|
||||
for (const [key, nestedValue] of Object.entries(value)) walk(nestedValue, [...path, key])
|
||||
return
|
||||
}
|
||||
|
||||
entries.push({
|
||||
configPath: path.join('.'),
|
||||
rawValue: value,
|
||||
})
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(sectionConfig)) walk(value, [section, key])
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
export function getConfigValue(config: Record<string, unknown>, configPath: string): unknown {
|
||||
let value: unknown = config
|
||||
|
||||
for (const segment of configPath.split('.')) {
|
||||
if (!isRecord(value) || !Object.prototype.hasOwnProperty.call(value, segment)) {
|
||||
return undefined
|
||||
}
|
||||
value = value[segment]
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* A redirect allow-list is a set, not a sequence: accepts either a comma-separated string or an
|
||||
* array, and returns trimmed, deduped, sorted entries so two lists holding the same URLs compare
|
||||
* equal regardless of how they were written.
|
||||
*/
|
||||
export function normalizeRedirectUrls(value: unknown): string[] {
|
||||
const urls = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : []
|
||||
|
||||
return Array.from(
|
||||
new Set(urls.filter((url): url is string => typeof url === 'string').map((url) => url.trim()))
|
||||
)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
}
|
||||
|
||||
const FILE_SIZE_PATTERN = /^(\d+(?:\.\d+)?)\s*(B|KB|KIB|MB|MIB|GB|GIB)?$/i
|
||||
const FILE_SIZE_UNIT_ALIASES: Record<string, StorageSizeUnits> = {
|
||||
B: StorageSizeUnits.BYTES,
|
||||
KB: StorageSizeUnits.KB,
|
||||
KIB: StorageSizeUnits.KB,
|
||||
MB: StorageSizeUnits.MB,
|
||||
MIB: StorageSizeUnits.MB,
|
||||
GB: StorageSizeUnits.GB,
|
||||
GIB: StorageSizeUnits.GB,
|
||||
}
|
||||
|
||||
/**
|
||||
* config.toml declares storage.file_size_limit as a human string (e.g. "50MiB"); the v2 project
|
||||
* config returns the same setting in bytes. Parse the former so both sides compare as bytes.
|
||||
*/
|
||||
function parseFileSizeToBytes(value: unknown): unknown {
|
||||
if (typeof value === 'number') return value
|
||||
if (typeof value !== 'string') return value
|
||||
|
||||
const match = FILE_SIZE_PATTERN.exec(value.trim())
|
||||
if (!match) return value
|
||||
|
||||
const [, amount, unitSuffix] = match
|
||||
const unit = unitSuffix
|
||||
? FILE_SIZE_UNIT_ALIASES[unitSuffix.toUpperCase()]
|
||||
: StorageSizeUnits.BYTES
|
||||
if (!unit) return value
|
||||
|
||||
return convertToBytes(Number(amount), unit)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
// This file should be temporary and will be removed once we publish a "@supabase/config" package.
|
||||
|
||||
// Mirrors the shape produced by parsing a project's `supabase/config.toml`.
|
||||
// Every field is optional since config.toml sections are all opt-in, and
|
||||
// objects allow unknown keys since users can add custom sections/providers.
|
||||
export const gitHubConfigTomlSchema = z.object({
|
||||
project_id: z.string().optional(),
|
||||
// `code` when the config is owned by the repository, which is what lets drift detection fall back
|
||||
// to a field's hosted default when config.toml is silent on it.
|
||||
config_source: z.string().optional(),
|
||||
api: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
port: z.number().optional(),
|
||||
schemas: z.array(z.string()).optional(),
|
||||
extra_search_path: z.array(z.string()).optional(),
|
||||
max_rows: z.number().optional(),
|
||||
tls: z.object({ enabled: z.boolean().optional() }).passthrough().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
db: z
|
||||
.object({
|
||||
port: z.number().optional(),
|
||||
shadow_port: z.number().optional(),
|
||||
major_version: z.number().optional(),
|
||||
pooler: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
port: z.number().optional(),
|
||||
pool_mode: z.string().optional(),
|
||||
default_pool_size: z.number().optional(),
|
||||
max_client_conn: z.number().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
migrations: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
schema_paths: z.array(z.string()).optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
seed: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
sql_paths: z.array(z.string()).optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
network_restrictions: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
allowed_cidrs: z.array(z.string()).optional(),
|
||||
allowed_cidrs_v6: z.array(z.string()).optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
realtime: z.object({ enabled: z.boolean().optional() }).passthrough().optional(),
|
||||
studio: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
port: z.number().optional(),
|
||||
api_url: z.string().optional(),
|
||||
openai_api_key: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
inbucket: z
|
||||
.object({ enabled: z.boolean().optional(), port: z.number().optional() })
|
||||
.passthrough()
|
||||
.optional(),
|
||||
storage: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
file_size_limit: z.union([z.string(), z.number()]).optional(),
|
||||
s3_protocol: z.object({ enabled: z.boolean().optional() }).passthrough().optional(),
|
||||
analytics: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
max_namespaces: z.number().optional(),
|
||||
max_tables: z.number().optional(),
|
||||
max_catalogs: z.number().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
vector: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
max_buckets: z.number().optional(),
|
||||
max_indexes: z.number().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
auth: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
site_url: z.string().nullable().optional(),
|
||||
additional_redirect_urls: z.array(z.string()).optional(),
|
||||
jwt_expiry: z.number().optional(),
|
||||
enable_refresh_token_rotation: z.boolean().optional(),
|
||||
refresh_token_reuse_interval: z.number().optional(),
|
||||
enable_signup: z.boolean().optional(),
|
||||
enable_anonymous_sign_ins: z.boolean().optional(),
|
||||
enable_manual_linking: z.boolean().optional(),
|
||||
minimum_password_length: z.number().optional(),
|
||||
password_requirements: z.string().nullable().optional(),
|
||||
rate_limit: z
|
||||
.object({
|
||||
email_sent: z.number().optional(),
|
||||
sms_sent: z.number().optional(),
|
||||
anonymous_users: z.number().optional(),
|
||||
token_refresh: z.number().optional(),
|
||||
sign_in_sign_ups: z.number().optional(),
|
||||
token_verifications: z.number().optional(),
|
||||
web3: z.number().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
email: z
|
||||
.object({
|
||||
enable_signup: z.boolean().optional(),
|
||||
double_confirm_changes: z.boolean().optional(),
|
||||
enable_confirmations: z.boolean().optional(),
|
||||
secure_password_change: z.boolean().optional(),
|
||||
max_frequency: z.string().optional(),
|
||||
otp_length: z.number().optional(),
|
||||
otp_expiry: z.number().optional(),
|
||||
template: z
|
||||
.record(
|
||||
z.string(),
|
||||
z
|
||||
.object({ subject: z.string().optional(), content_path: z.string().optional() })
|
||||
.passthrough()
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
sms: z
|
||||
.object({
|
||||
enable_signup: z.boolean().optional(),
|
||||
enable_confirmations: z.boolean().optional(),
|
||||
template: z.string().nullable().optional(),
|
||||
max_frequency: z.string().optional(),
|
||||
provider: z.string().nullable().optional(),
|
||||
twilio: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
account_sid: z.string().nullable().optional(),
|
||||
message_service_sid: z.string().nullable().optional(),
|
||||
auth_token: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
mfa: z
|
||||
.object({
|
||||
max_enrolled_factors: z.number().optional(),
|
||||
totp: z
|
||||
.object({
|
||||
enroll_enabled: z.boolean().optional(),
|
||||
verify_enabled: z.boolean().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
phone: z
|
||||
.object({
|
||||
enroll_enabled: z.boolean().optional(),
|
||||
verify_enabled: z.boolean().optional(),
|
||||
otp_length: z.number().optional(),
|
||||
template: z.string().optional(),
|
||||
max_frequency: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
external: z
|
||||
.record(
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
client_id: z.string().nullable().optional(),
|
||||
secret: z.string().nullable().optional(),
|
||||
redirect_uri: z.string().nullable().optional(),
|
||||
url: z.string().nullable().optional(),
|
||||
skip_nonce_check: z.boolean().optional(),
|
||||
email_optional: z.boolean().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
)
|
||||
.optional(),
|
||||
web3: z
|
||||
.object({
|
||||
solana: z.object({ enabled: z.boolean().optional() }).passthrough().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
third_party: z
|
||||
.record(z.string(), z.object({ enabled: z.boolean().optional() }).passthrough())
|
||||
.optional(),
|
||||
oauth_server: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
authorization_url_path: z.string().optional(),
|
||||
allow_dynamic_registration: z.boolean().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
functions: z
|
||||
.record(z.string(), z.object({ verify_jwt: z.boolean().optional() }).passthrough())
|
||||
.optional(),
|
||||
edge_runtime: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
policy: z.string().optional(),
|
||||
inspector_port: z.number().optional(),
|
||||
deno_version: z.number().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
analytics: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
port: z.number().optional(),
|
||||
backend: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
remotes: z
|
||||
.record(z.string(), z.object({ project_id: z.string().optional() }).passthrough())
|
||||
.optional(),
|
||||
experimental: z
|
||||
.object({
|
||||
orioledb_version: z.string().optional(),
|
||||
s3_host: z.string().optional(),
|
||||
s3_region: z.string().optional(),
|
||||
s3_access_key: z.string().optional(),
|
||||
s3_secret_key: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export type GitHubConfigToml = z.infer<typeof gitHubConfigTomlSchema>
|
||||
@@ -77,6 +77,7 @@ export const useCreateAndExposeAPISchemaMutation = ({
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: databaseKeys.schemas(projectRef) }),
|
||||
queryClient.invalidateQueries({ queryKey: configKeys.postgrest(projectRef) }),
|
||||
queryClient.invalidateQueries({ queryKey: configKeys.projectConfig(projectRef) }),
|
||||
])
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { configKeys } from '../config/keys'
|
||||
import { authKeys } from './keys'
|
||||
import type { components } from '@/data/api'
|
||||
import { handleError, patch } from '@/data/fetchers'
|
||||
@@ -52,8 +53,10 @@ export const useAuthConfigUpdateMutation = ({
|
||||
|
||||
await onSuccess?.(data, variables, context)
|
||||
|
||||
queryClient
|
||||
.invalidateQueries({ queryKey: lintKeys.lint(projectRef) })
|
||||
Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: lintKeys.lint(projectRef) }),
|
||||
queryClient.invalidateQueries({ queryKey: configKeys.projectConfig(projectRef) }),
|
||||
])
|
||||
.then(() =>
|
||||
queryClient.refetchQueries({
|
||||
queryKey: lintKeys.lint(projectRef),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { configKeys } from '../config/keys'
|
||||
import { authKeys } from './keys'
|
||||
import type { components } from '@/data/api'
|
||||
import { handleError, patch } from '@/data/fetchers'
|
||||
@@ -37,7 +38,10 @@ export const useAuthHooksUpdateMutation = ({
|
||||
mutationFn: (vars) => updateAuthHooks(vars),
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef } = variables
|
||||
await queryClient.invalidateQueries({ queryKey: authKeys.authConfig(projectRef) })
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: authKeys.authConfig(projectRef) }),
|
||||
queryClient.invalidateQueries({ queryKey: configKeys.projectConfig(projectRef) }),
|
||||
])
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { toast } from 'sonner'
|
||||
import type { ProjectAuthConfigData } from './auth-config-query'
|
||||
import { authKeys } from './keys'
|
||||
import { type AuthTemplateResetType } from '@/components/interfaces/Auth/EmailTemplates/EmailTemplates.types'
|
||||
import { configKeys } from '@/data/config/keys'
|
||||
import { handleError, post } from '@/data/fetchers'
|
||||
import { lintKeys } from '@/data/lint/keys'
|
||||
import type { ResponseError, UseCustomMutationOptions } from '@/types'
|
||||
@@ -39,10 +40,13 @@ export const useAuthTemplateResetMutation = ({
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef } = variables
|
||||
queryClient.setQueryData<ProjectAuthConfigData>(authKeys.authConfig(projectRef), data)
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: authKeys.authConfig(projectRef),
|
||||
refetchType: 'none',
|
||||
})
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: authKeys.authConfig(projectRef),
|
||||
refetchType: 'none',
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: configKeys.projectConfig(projectRef) }),
|
||||
])
|
||||
await onSuccess?.(data, variables, context)
|
||||
|
||||
void queryClient
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { components } from 'api-types'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { gitHubConfigTomlSchema } from '@/components/interfaces/ConfigDrift/github-config.types'
|
||||
import { get, handleError } from '@/data/fetchers'
|
||||
import type { UseCustomQueryOptions } from '@/types'
|
||||
|
||||
@@ -11,10 +9,7 @@ export type GitHubConfigVariables = {
|
||||
branch?: string
|
||||
}
|
||||
|
||||
type GithubConfigQueryResponse =
|
||||
components['schemas']['GetGitHubConnectionConfigResponse_Output'] & {
|
||||
config: z.infer<typeof gitHubConfigTomlSchema>
|
||||
}
|
||||
type GithubConfigQueryResponse = components['schemas']['GetGitHubConnectionConfigResponse_Output']
|
||||
|
||||
export const githubConfigKeys = {
|
||||
all: ['github-config'] as const,
|
||||
@@ -40,17 +35,7 @@ export async function getGitHubConfig(
|
||||
)
|
||||
|
||||
if (error) return handleError(error)
|
||||
const parsed = gitHubConfigTomlSchema.safeParse(data.config)
|
||||
if (parsed.error) {
|
||||
return handleError(
|
||||
new Error(`Invalid response from Github config API: ${parsed.error.message}`)
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
...data,
|
||||
config: parsed.data,
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export const useGitHubConfigQuery = <TData = GithubConfigQueryResponse>(
|
||||
|
||||
@@ -50,9 +50,10 @@ export const usePostgresConfigurationUpdateMutation = ({
|
||||
mutationFn: (vars) => updatePostgresConfiguration(vars),
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef } = variables
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: configKeys.postgresConfig(projectRef),
|
||||
})
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: configKeys.postgresConfig(projectRef) }),
|
||||
queryClient.invalidateQueries({ queryKey: configKeys.projectConfig(projectRef) }),
|
||||
])
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
|
||||
@@ -67,6 +67,7 @@ export const useProjectPostgrestConfigUpdateMutation = ({
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: configKeys.postgrest(projectRef) }),
|
||||
queryClient.invalidateQueries({ queryKey: lintKeys.lint(projectRef) }),
|
||||
queryClient.invalidateQueries({ queryKey: configKeys.projectConfig(projectRef) }),
|
||||
])
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
|
||||
@@ -51,7 +51,10 @@ export const useProjectStorageConfigUpdateUpdateMutation = ({
|
||||
mutationFn: (vars) => updateProjectStorageConfigUpdate(vars),
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef } = variables
|
||||
await queryClient.invalidateQueries({ queryKey: configKeys.storage(projectRef) })
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: configKeys.storage(projectRef) }),
|
||||
queryClient.invalidateQueries({ queryKey: configKeys.projectConfig(projectRef) }),
|
||||
])
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { configKeys } from '../config/keys'
|
||||
import { databaseKeys } from './keys'
|
||||
import type { components } from '@/data/api'
|
||||
import { handleError, patch } from '@/data/fetchers'
|
||||
@@ -58,7 +59,10 @@ export const usePgbouncerConfigurationUpdateMutation = ({
|
||||
mutationFn: (vars) => updatePgbouncerConfiguration(vars),
|
||||
async onSuccess(data, variables, context) {
|
||||
const { ref } = variables
|
||||
await queryClient.invalidateQueries({ queryKey: databaseKeys.pgbouncerConfig(ref) })
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: databaseKeys.pgbouncerConfig(ref) }),
|
||||
queryClient.invalidateQueries({ queryKey: configKeys.projectConfig(ref) }),
|
||||
])
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { networkRestrictionKeys } from './keys'
|
||||
import { configKeys } from '@/data/config/keys'
|
||||
import { handleError, post } from '@/data/fetchers'
|
||||
import type { ResponseError, UseCustomMutationOptions } from '@/types'
|
||||
|
||||
@@ -51,7 +52,10 @@ export const useNetworkRestrictionsApplyMutation = ({
|
||||
mutationFn: (vars) => applyNetworkRestrictions(vars),
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef } = variables
|
||||
await queryClient.invalidateQueries({ queryKey: networkRestrictionKeys.list(projectRef) })
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: networkRestrictionKeys.list(projectRef) }),
|
||||
queryClient.invalidateQueries({ queryKey: configKeys.projectConfig(projectRef) }),
|
||||
])
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { toast } from 'sonner'
|
||||
|
||||
import { realtimeKeys } from './keys'
|
||||
import type { components } from '@/data/api'
|
||||
import { configKeys } from '@/data/config/keys'
|
||||
import { handleError, patch } from '@/data/fetchers'
|
||||
import type { ResponseError, UseCustomMutationOptions } from '@/types'
|
||||
|
||||
@@ -75,7 +76,10 @@ export const useRealtimeConfigurationUpdateMutation = ({
|
||||
mutationFn: (vars) => updateRealtimeConfiguration(vars),
|
||||
async onSuccess(data, variables, context) {
|
||||
const { ref } = variables
|
||||
await queryClient.invalidateQueries({ queryKey: realtimeKeys.configuration(ref) })
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: realtimeKeys.configuration(ref) }),
|
||||
queryClient.invalidateQueries({ queryKey: configKeys.projectConfig(ref) }),
|
||||
])
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { sslEnforcementKeys } from './keys'
|
||||
import { configKeys } from '@/data/config/keys'
|
||||
import { handleError, put } from '@/data/fetchers'
|
||||
import { jitDbAccessKeys } from '@/data/jit-db-access/keys'
|
||||
import type { ResponseError, UseCustomMutationOptions } from '@/types'
|
||||
@@ -52,6 +53,7 @@ export const useSSLEnforcementUpdateMutation = ({
|
||||
// JIT DB access can report `unavailableReason: 'ssl_enforcement_required'`,
|
||||
// so its status needs to be refetched whenever SSL enforcement changes.
|
||||
await queryClient.invalidateQueries({ queryKey: jitDbAccessKeys.list(projectRef) })
|
||||
await queryClient.invalidateQueries({ queryKey: configKeys.projectConfig(projectRef) })
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { toast } from 'sonner'
|
||||
|
||||
import { subscriptionKeys } from './keys'
|
||||
import type { AddonVariantId } from './types'
|
||||
import { configKeys } from '@/data/config/keys'
|
||||
import { del, handleError } from '@/data/fetchers'
|
||||
import type { ResponseError, UseCustomMutationOptions } from '@/types'
|
||||
|
||||
@@ -49,7 +50,10 @@ export const useProjectAddonRemoveMutation = ({
|
||||
const { projectRef } = variables
|
||||
// [Joshen] Only invalidate addons, not subscriptions, as AddOn section in
|
||||
// subscription page is using AddOn react query
|
||||
await queryClient.invalidateQueries({ queryKey: subscriptionKeys.addons(projectRef) })
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: subscriptionKeys.addons(projectRef) }),
|
||||
queryClient.invalidateQueries({ queryKey: configKeys.projectConfig(projectRef) }),
|
||||
])
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { toast } from 'sonner'
|
||||
|
||||
import { subscriptionKeys } from './keys'
|
||||
import type { AddonVariantId, ProjectAddonType } from './types'
|
||||
import { configKeys } from '@/data/config/keys'
|
||||
import { handleError, post } from '@/data/fetchers'
|
||||
import type { ResponseError, UseCustomMutationOptions } from '@/types'
|
||||
|
||||
@@ -54,7 +55,10 @@ export const useProjectAddonUpdateMutation = ({
|
||||
mutationFn: (vars) => updateSubscriptionAddon(vars),
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef } = variables
|
||||
await queryClient.invalidateQueries({ queryKey: subscriptionKeys.addons(projectRef) })
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: subscriptionKeys.addons(projectRef) }),
|
||||
queryClient.invalidateQueries({ queryKey: configKeys.projectConfig(projectRef) }),
|
||||
])
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+145
-4
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user