diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/BigQuery.utils.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/BigQuery.utils.ts index 7b9e1c25a366d..5bc5181d1558a 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/BigQuery.utils.ts +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/BigQuery.utils.ts @@ -7,18 +7,51 @@ export type BigQueryValidationIssue = { message: string } -const BIGQUERY_REQUIRED_FIELDS: { path: BigQueryFieldPath; message: string }[] = [ +export const BIGQUERY_SERVICE_ACCOUNT_JSON_MESSAGE = 'Service account key must be valid JSON' + +const BIGQUERY_REQUIRED_FIELDS: { + path: Exclude + message: string +}[] = [ { path: 'projectId', message: 'Project ID is required' }, { path: 'datasetId', message: 'Dataset ID is required' }, - { path: 'serviceAccountKey', message: 'Service account key is required' }, ] +const isValidJsonString = (value: string) => { + try { + JSON.parse(value) + return true + } catch { + return false + } +} + export const getBigQueryValidationIssues = ( data: Pick, - options: { secretsOptional?: boolean } = {} -): BigQueryValidationIssue[] => - BIGQUERY_REQUIRED_FIELDS.filter(({ path }) => { - if (options.secretsOptional && path === 'serviceAccountKey') return false + options: { secretsOptional?: boolean; validateJson?: boolean } = {} +): BigQueryValidationIssue[] => { + const { secretsOptional = false, validateJson = true } = options + const issues: BigQueryValidationIssue[] = BIGQUERY_REQUIRED_FIELDS.filter( + ({ path }) => !data[path]?.trim().length + ).map(({ path, message }) => ({ path, message })) - return !data[path]?.trim().length - }) + const serviceAccountKey = data.serviceAccountKey?.trim() ?? '' + + if (!serviceAccountKey) { + if (!secretsOptional) { + issues.push({ path: 'serviceAccountKey', message: 'Service account key is required' }) + } + return issues + } + + // JSON shape is checked on submit only. Live onChange validation would fail on every + // keystroke while the user is still pasting or typing a key. + if (validateJson && !isValidJsonString(serviceAccountKey)) { + issues.push({ + path: 'serviceAccountKey', + message: BIGQUERY_SERVICE_ACCOUNT_JSON_MESSAGE, + }) + } + + return issues +} diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.test.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.test.ts index 3c55a6a9de0fe..05470fe0dea77 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.test.ts +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.test.ts @@ -769,6 +769,58 @@ describe('DestinationForm.utils BigQuery', () => { expect(issues).toEqual([]) }) + it('rejects invalid JSON in the service account key', () => { + const issues = getBigQueryValidationIssues({ + projectId: 'my-project', + datasetId: 'my_dataset', + serviceAccountKey: '{ "type": "service_account" ', + }) + + expect(issues).toEqual([ + { path: 'serviceAccountKey', message: 'Service account key must be valid JSON' }, + ]) + }) + + it('skips JSON shape checks when validateJson is false', () => { + const issues = getBigQueryValidationIssues( + { + projectId: 'my-project', + datasetId: 'my_dataset', + serviceAccountKey: '{ "type": "service_account" ', + }, + { validateJson: false } + ) + + expect(issues).toEqual([]) + }) + + it('rejects non-JSON text in the service account key', () => { + const issues = getBigQueryValidationIssues({ + projectId: 'my-project', + datasetId: 'my_dataset', + serviceAccountKey: 'not-json', + }) + + expect(issues).toEqual([ + { path: 'serviceAccountKey', message: 'Service account key must be valid JSON' }, + ]) + }) + + it('validates JSON when replacing credentials in edit mode', () => { + const issues = getBigQueryValidationIssues( + { + projectId: 'my-project', + datasetId: 'my_dataset', + serviceAccountKey: '{ invalid', + }, + { secretsOptional: true } + ) + + expect(issues).toEqual([ + { path: 'serviceAccountKey', message: 'Service account key must be valid JSON' }, + ]) + }) + it('allows an omitted BigQuery service account key in edit mode', () => { const issues = getBigQueryValidationIssues( { diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationNameInput.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationNameInput.tsx index c04a4c1a2dce9..a95827fa29d23 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationNameInput.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationNameInput.tsx @@ -16,7 +16,15 @@ export const DestinationNameInput = ({ form }: DestinationNameInputProps) => { render={({ field }) => ( - + )} diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PipelineRegionField.test.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PipelineRegionField.test.tsx new file mode 100644 index 0000000000000..0afb0d8347395 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PipelineRegionField.test.tsx @@ -0,0 +1,36 @@ +import { screen } from '@testing-library/react' +import { describe, expect, test } from 'vitest' + +import { + getPipelineRegionDescription, + PIPELINE_REGION, + PipelineRegionField, +} from './PipelineRegionField' +import { customRender } from '@/tests/lib/custom-render' + +describe('getPipelineRegionDescription', () => { + test('includes the fixed-region note and destination-specific hint', () => { + expect(getPipelineRegionDescription('BigQuery')).toBe( + 'All pipelines run from this region. Choose a nearby BigQuery dataset where possible.' + ) + }) + + test('falls back to a generic destination hint', () => { + expect(getPipelineRegionDescription()).toBe( + 'All pipelines run from this region. Choose a nearby destination region where possible.' + ) + }) +}) + +describe('PipelineRegionField', () => { + test('shows the managed region as read-only information, not a combobox', () => { + customRender() + + expect(screen.getByText('Pipeline region')).toBeInTheDocument() + expect(screen.getByText(PIPELINE_REGION.displayName)).toBeInTheDocument() + expect(screen.getByText(PIPELINE_REGION.code)).toBeInTheDocument() + expect(screen.getByText(/All pipelines run from this region/)).toBeInTheDocument() + expect(screen.getByText(/nearby BigQuery dataset/)).toBeInTheDocument() + expect(screen.queryByRole('combobox')).not.toBeInTheDocument() + }) +}) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PipelineRegionField.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PipelineRegionField.tsx new file mode 100644 index 0000000000000..9cd0941cbf90c --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PipelineRegionField.tsx @@ -0,0 +1,59 @@ +import { AWS_REGIONS } from 'shared-data' +import { cn } from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + +import type { DestinationType } from '../DestinationPanel.types' +import { RegionFlag } from '@/components/ui/RegionFlag' +import { IS_STAGING_OR_LOCAL } from '@/lib/constants' + +// Pipelines always run from a single fixed region per environment, regardless of the source +// project's region. +export const PIPELINE_REGION = IS_STAGING_OR_LOCAL + ? AWS_REGIONS.SOUTHEAST_ASIA + : AWS_REGIONS.CENTRAL_EU + +const DESTINATION_REGION_HINT: Record = { + BigQuery: 'Choose a nearby BigQuery dataset where possible.', + 'Analytics Bucket': 'Keep this bucket in the same region where possible.', + DuckLake: 'Keep catalog and object storage close to this region where possible.', + Snowflake: 'Choose a nearby Snowflake account region where possible.', + ClickHouse: 'Choose a nearby ClickHouse cluster where possible.', +} + +export const getPipelineRegionDescription = (destinationType?: DestinationType) => { + const destinationHint = destinationType + ? DESTINATION_REGION_HINT[destinationType] + : 'Choose a nearby destination region where possible.' + + return `All pipelines run from this region. ${destinationHint}` +} + +export const PipelineRegionField = ({ + destinationType, + className, +}: { + destinationType?: DestinationType + className?: string +}) => { + return ( + +
+ + {PIPELINE_REGION.displayName} + + {PIPELINE_REGION.code} + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.test.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.test.tsx index 675c129e84b45..d6313a5772d74 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.test.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.test.tsx @@ -71,6 +71,7 @@ vi.mock('ui', () => ({ {children} ), + cn: (...args: unknown[]) => args.filter(Boolean).join(' '), DialogSectionSeparator: () => null, Form: ({ children }: PropsWithChildren) => children, Select: ({ children }: PropsWithChildren) =>
{children}
, diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx index 61670a6b66cdb..b4f8f25a13f87 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx @@ -5,25 +5,9 @@ import { AnimatePresence, motion } from 'framer-motion' import { Loader2 } from 'lucide-react' import { RefObject, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useForm, useWatch } from 'react-hook-form' -import { AWS_REGIONS } from 'shared-data' import { toast } from 'sonner' -import { - Button, - DialogSectionSeparator, - Form, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - SheetFooter, - SheetSection, - Tooltip, - TooltipContent, - TooltipTrigger, -} from 'ui' +import { Button, DialogSectionSeparator, Form, SheetFooter, SheetSection } from 'ui' import { Admonition } from 'ui-patterns/Admonition' -import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import * as z from 'zod' import { @@ -37,7 +21,10 @@ import { type DestinationType, type ExistingDestination } from '../DestinationPa import { AdvancedSettings } from './AdvancedSettings' import { getAnalyticsBucketValidationIssues } from './AnalyticsBucket/AnalyticsBucket.utils' import { AnalyticsBucketFields } from './AnalyticsBucket/Fields' -import { getBigQueryValidationIssues } from './BigQuery/BigQuery.utils' +import { + BIGQUERY_SERVICE_ACCOUNT_JSON_MESSAGE, + getBigQueryValidationIssues, +} from './BigQuery/BigQuery.utils' import { BigQueryFields } from './BigQuery/Fields' import { getClickHouseValidationIssues } from './ClickHouse/ClickHouse.utils' import { ClickHouseFields } from './ClickHouse/Fields' @@ -54,6 +41,7 @@ import { DuckLakeFields } from './DuckLake/Fields' import { NewPublicationPanel } from './NewPublicationPanel' import { NoDestinationsAvailable } from './NoDestinationsAvailable' import { PipelineCostDialog } from './PipelineCostDialog' +import { PipelineRegionField } from './PipelineRegionField' import { PublicationSelection } from './PublicationSelection' import { SnowflakeFields } from './Snowflake/Fields' import { getSnowflakeValidationIssues } from './Snowflake/Snowflake.utils' @@ -62,8 +50,6 @@ import { useDestinationForm } from './useDestinationForm' import { ValidationFailuresSection } from './ValidationFailuresSection' import { ValidationWarningsDialog } from './ValidationWarningsDialog' import { CreateAnalyticsBucketSheet } from '@/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketSheet' -import { InlineLinkClassName } from '@/components/ui/InlineLink' -import { RegionFlag } from '@/components/ui/RegionFlag' import { useAPIKeys } from '@/data/api-keys/api-keys-query' import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query' import { useReplicationDestinationByIdQuery } from '@/data/replication/destination-by-id-query' @@ -71,14 +57,9 @@ import { useReplicationPipelineByIdQuery } from '@/data/replication/pipeline-by- import { useReplicationPublicationsQuery } from '@/data/replication/publications-query' import { useReplicationSourceId } from '@/data/replication/sources-query' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' -import { IS_STAGING_OR_LOCAL } from '@/lib/constants' const formId = 'destination-editor' -// Pipelines always run out of a single fixed region per environment, regardless of the source -// project's region. -const PIPELINE_REGION = IS_STAGING_OR_LOCAL ? AWS_REGIONS.SOUTHEAST_ASIA : AWS_REGIONS.CENTRAL_EU - interface DestinationFormProps { selectedType: DestinationType visible: boolean @@ -232,11 +213,12 @@ export const DestinationForm = ({ } if (selectedType === 'BigQuery') { - getBigQueryValidationIssues(data, { secretsOptional: editMode }).forEach( - ({ path, message }) => { - addRequiredFieldError(path, message) - } - ) + getBigQueryValidationIssues(data, { + secretsOptional: editMode, + validateJson: false, + }).forEach(({ path, message }) => { + addRequiredFieldError(path, message) + }) } else if (selectedType === 'Analytics Bucket') { getAnalyticsBucketValidationIssues(data, { secretsOptional: editMode, @@ -342,6 +324,16 @@ export const DestinationForm = ({ }), } + if (selectedType === 'BigQuery') { + const jsonIssue = getBigQueryValidationIssues(data, { secretsOptional: editMode }).find( + (issue) => issue.message === BIGQUERY_SERVICE_ACCOUNT_JSON_MESSAGE + ) + if (jsonIssue) { + form.setError(jsonIssue.path, { message: jsonIssue.message }) + return + } + } + // Pipeline prerequisite validation models a new pipeline and cannot // account for resources already owned by an existing pipeline. Edits keep // the established direct-update flow after pruning stale table ids. @@ -469,42 +461,7 @@ export const DestinationForm = ({ onSelectNewPublication={() => setPublicationPanelVisible(true)} /> - - Pipelines run in{' '} - - - {PIPELINE_REGION.displayName} - - {PIPELINE_REGION.code} - - . In your destination provider, choose the closest available region. - - } - > - - + diff --git a/apps/www/app/api-v2/md-404/[[...path]]/route.ts b/apps/www/app/api-v2/md-404/[[...path]]/route.ts new file mode 100644 index 0000000000000..4aa71ab7dd5dd --- /dev/null +++ b/apps/www/app/api-v2/md-404/[[...path]]/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server' + +const MARKDOWN_404_HEADERS = { + 'Content-Type': 'text/markdown; charset=utf-8', + 'X-Content-Type-Options': 'nosniff', + 'Cache-Control': 'no-store', + Vary: 'Accept', +} + +function buildMarkdown404Body(requestPath: string): string { + const safePath = requestPath.replace(/[^\x20-\x7E]/g, '').replaceAll('`', '') + return `# 404 Not Found + +\`${safePath}\` does not exist on supabase.com. + +Explore instead: + +- [Documentation](https://supabase.com/docs) +- [Sitemap](https://supabase.com/sitemap.xml) +- [llms.txt](https://supabase.com/llms.txt) +` +} + +export async function GET(_request: Request, { params }: { params: Promise<{ path?: string[] }> }) { + const { path } = await params + return new NextResponse(buildMarkdown404Body('/' + (path ?? []).join('/')), { + status: 404, + headers: MARKDOWN_404_HEADERS, + }) +} diff --git a/apps/www/next.config.mjs b/apps/www/next.config.mjs index 9286c3c07afc0..0f4d2489277d1 100644 --- a/apps/www/next.config.mjs +++ b/apps/www/next.config.mjs @@ -80,7 +80,7 @@ const nextConfig = { */ outputFileTracingIncludes: { '/llms.txt': ['../docs/content/guides/*'], - '/llms-full.txt': ['../docs/public/markdown/guides/**/*.md' ], + '/llms-full.txt': ['../docs/public/markdown/guides/**/*.md'], }, reactStrictMode: true, images: { @@ -194,7 +194,20 @@ const nextConfig = { ] }, async rewrites() { - return rewrites + return { + afterFiles: rewrites, + fallback: [ + { + source: '/:path(.*\\.md)', + destination: '/api-v2/md-404/:path', + }, + { + source: '/:path*', + has: [{ type: 'header', key: 'accept', value: '.*text/(markdown|\\*).*' }], + destination: '/api-v2/md-404/:path*', + }, + ], + } }, async redirects() { // For /docs/guides/ redirects, auto-generate .md variants so renamed/deleted pages diff --git a/apps/www/next.config.test.ts b/apps/www/next.config.test.ts index e88f7141eced4..083c7a260d55b 100644 --- a/apps/www/next.config.test.ts +++ b/apps/www/next.config.test.ts @@ -27,10 +27,11 @@ describe('next.config.mjs', () => { it('routes the library and permanently redirects the legacy UI URLs', async () => { const { default: config } = (await import('./next.config.mjs')) as { default: NextConfig } - const rewrites = (await config.rewrites?.()) || [] + const rewrites = await config.rewrites?.() + const afterFiles = (rewrites && 'afterFiles' in rewrites && rewrites.afterFiles) || [] const redirects = (await config.redirects?.()) || [] - expect(rewrites).toEqual( + expect(afterFiles).toEqual( expect.arrayContaining([ expect.objectContaining({ source: '/library' }), expect.objectContaining({ source: '/library/:path*' }), @@ -61,6 +62,33 @@ describe('next.config.mjs', () => { ).toBeLessThan(redirects.findIndex((redirect) => redirect.source === '/ui/:path*')) }) + it('routes unmatched markdown-negotiated paths to the md-404 handler via fallback rewrites', async () => { + const { default: config } = (await import('./next.config.mjs')) as { default: NextConfig } + const rewrites = await config.rewrites?.() + const fallback = (rewrites && 'fallback' in rewrites && rewrites.fallback) || [] + + const mdSuffixRule = fallback.find((rule) => rule.destination === '/api-v2/md-404/:path') + const acceptRule = fallback.find((rule) => rule.destination === '/api-v2/md-404/:path*') + + expect(mdSuffixRule).toBeDefined() + expect(acceptRule?.has).toEqual([ + { type: 'header', key: 'accept', value: '.*text/(markdown|\\*).*' }, + ]) + + expect(getPathMatch(mdSuffixRule!.source)('/definitely-not-a-page.md')).toBeTruthy() + expect(getPathMatch(mdSuffixRule!.source)('/nested/definitely/not-a-page.md')).toBeTruthy() + expect(getPathMatch(mdSuffixRule!.source)('/definitely-not-a-page')).toBe(false) + expect(getPathMatch(acceptRule!.source)('/definitely-not-a-page')).toBeTruthy() + + const acceptHeaderRegex = new RegExp(`^${acceptRule!.has![0].value}$`) + expect(acceptHeaderRegex.test('text/markdown')).toBe(true) + expect(acceptHeaderRegex.test('text/html, text/markdown;q=0.9')).toBe(true) + expect(acceptHeaderRegex.test('text/*')).toBe(true) + expect( + acceptHeaderRegex.test('text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8') + ).toBe(false) + }) + it('permanently redirects the legacy root markdown aliases to /index.md', async () => { const { default: config } = (await import('./next.config.mjs')) as { default: NextConfig } const redirects = (await config.redirects?.()) || []