diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index d6f6df5..40ce5e7 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -16,5 +16,7 @@ jobs: run: npm ci - name: Build run: npm run build - - name: Lint + - name: Lint run: npm run lint + - name: Test (format-vector conformance) + run: npm test diff --git a/package.json b/package.json index ceac95c..1ffc8aa 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "./dist/webcomponent.js" ], "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "npx -y tsx scripts/check-format-vectors.ts", "build": "rollup -c", "dev": "cd dev/vite && npm run dev", "dev:setup": "npm run build && cd dev/vite && npm install", diff --git a/scripts/check-format-vectors.ts b/scripts/check-format-vectors.ts new file mode 100644 index 0000000..73fe35e --- /dev/null +++ b/scripts/check-format-vectors.ts @@ -0,0 +1,109 @@ +/** + * Asserts the local FORMAT_PATTERNS copy against the canonical format accept/reject + * vectors — copied from `@stackone/core` `FORMAT_PATTERN_TEST_VECTORS` (connect repo, + * `packages/core/src/connector/specs/formatPatterns.vectors.ts`). + * + * The canonical registry and this local copy must pass exactly these vectors, so + * `stackone validate` and this hub can never disagree about what a format accepts. + * Keep both the registry copy (utils/zodSchema.ts) and these vectors in sync when a + * format changes. Run via `npm test`. + */ +import { FORMAT_PATTERNS } from '../src/modules/integration-picker/utils/zodSchema'; + +const FORMAT_PATTERN_TEST_VECTORS: Record = { + email: { + accepts: ['john@example.com', 'a.b+tag@sub.domain.co'], + rejects: ['not-an-email', 'a b@example.com', '@example.com', 'john@'], + }, + url: { + accepts: [ + 'https://api.example.com', + 'http://x.io/path?q=1', + 'http://localhost:3000', + 'https://1.2.3.4/p?q=1', + 'https://x.io/a?b=c#d', + ], + rejects: [ + 'example.com', + 'ftp://host', + '', + 'https://api.example.com extra text', + 'https://foo bar/baz', + 'https://', + 'https://?q=1', + 'https:///path', + 'https://#frag', + 'https://a b', + ], + }, + uri: { + accepts: ['https://api.example.com', 'mailto:x@y.z', 'urn:isbn:0451450523'], + rejects: ['no-scheme-here', '://missing', ''], + }, + uuid: { + accepts: ['123e4567-e89b-12d3-a456-426614174000', '123E4567-E89B-12D3-A456-426614174000'], + rejects: ['123e4567', 'zzze4567-e89b-12d3-a456-426614174000', ''], + }, + date: { + accepts: ['2026-07-06', '1999-12-31'], + rejects: ['06-07-2026', '2026/07/06', '2026-7-6', ''], + }, + datetime: { + accepts: [ + '2026-07-06T10:30:00', + '2026-07-06T10:30:00Z', + '2026-07-06T10:30:00+01:00', + '2026-07-06T10:30:00.123Z', + '2026-07-06T10:30:00z', + '2026-07-06T10:30:00+0100', + '2026-07-06T10:30:00+01', + ], + rejects: [ + '2026-07-06', + '10:30:00', + '', + '2026-07-06T10:30:00banana', + '2026-07-06T10:30:00Zzz', + ], + }, +}; + +let failures = 0; + +for (const [format, vectors] of Object.entries(FORMAT_PATTERN_TEST_VECTORS)) { + const pattern = FORMAT_PATTERNS[format as keyof typeof FORMAT_PATTERNS]; + if (!pattern) { + failures++; + console.error(`FAIL: registry is missing format "${format}"`); + continue; + } + for (const value of vectors.accepts) { + if (!pattern.test(value)) { + failures++; + console.error(`FAIL: ${format} should accept "${value}"`); + } + } + for (const value of vectors.rejects) { + if (pattern.test(value)) { + failures++; + console.error(`FAIL: ${format} should reject "${value}"`); + } + } +} + +// Reverse coverage: a format added to the registry (mirroring a new core format) with no +// vectors here would otherwise pass silently — the exact drift D3 exists to catch (a field +// with an unrecognised format renders unvalidated). Fail if any registry key lacks vectors. +for (const format of Object.keys(FORMAT_PATTERNS)) { + if (!FORMAT_PATTERN_TEST_VECTORS[format]) { + failures++; + console.error(`FAIL: no vectors for registry format "${format}"`); + } +} + +if (failures > 0) { + console.error(`${failures} format vector failure(s)`); + process.exit(1); +} + +console.log('All format vectors pass'); diff --git a/src/modules/integration-picker/components/IntegrationFields.tsx b/src/modules/integration-picker/components/IntegrationFields.tsx index 892b727..ae56fd8 100644 --- a/src/modules/integration-picker/components/IntegrationFields.tsx +++ b/src/modules/integration-picker/components/IntegrationFields.tsx @@ -224,6 +224,7 @@ interface IntegrationFieldsProps { onChange: (data: Record) => void; onValidationChange?: (isValid: boolean) => void; integrationName: string; + connectorKey?: string; editingSecrets?: Set; setEditingSecrets?: (updater: (prev: Set) => Set) => void; } @@ -277,6 +278,7 @@ export const IntegrationForm: React.FC = ({ error, onValidationChange, integrationName, + connectorKey, editingSecrets, setEditingSecrets, }) => { @@ -285,7 +287,7 @@ export const IntegrationForm: React.FC = ({ typeof f.key === 'object' ? JSON.stringify(f.key) : String(f.key), ); const { noticesBefore, noticesAfter } = partitionNotices(notices, fieldKeys); - const schema = useMemo(() => createFormSchema(fields), [fields]); + const schema = useMemo(() => createFormSchema(fields, connectorKey), [fields, connectorKey]); const defaultValues = useMemo(() => { const initialData: Record = {}; diff --git a/src/modules/integration-picker/components/IntegrationPickerContent.tsx b/src/modules/integration-picker/components/IntegrationPickerContent.tsx index 91fe11d..280819e 100644 --- a/src/modules/integration-picker/components/IntegrationPickerContent.tsx +++ b/src/modules/integration-picker/components/IntegrationPickerContent.tsx @@ -126,6 +126,7 @@ export const IntegrationPickerContent: React.FC = onChange={onChange} onValidationChange={onValidationChange} integrationName={connectorData.name} + connectorKey={connectorData.key} editingSecrets={editingSecrets} setEditingSecrets={setEditingSecrets} /> diff --git a/src/modules/integration-picker/components/views/IntegrationFormView.tsx b/src/modules/integration-picker/components/views/IntegrationFormView.tsx index 6bf971c..c862851 100644 --- a/src/modules/integration-picker/components/views/IntegrationFormView.tsx +++ b/src/modules/integration-picker/components/views/IntegrationFormView.tsx @@ -12,6 +12,7 @@ interface IntegrationFormViewProps { onChange: (data: Record) => void; onValidationChange?: (isValid: boolean) => void; integrationName: string; + connectorKey?: string; editingSecrets?: Set; setEditingSecrets?: (updater: (prev: Set) => Set) => void; } @@ -23,6 +24,7 @@ export const IntegrationFormView: React.FC = ({ onChange, onValidationChange, integrationName, + connectorKey, editingSecrets, setEditingSecrets, }) => { @@ -34,6 +36,7 @@ export const IntegrationFormView: React.FC = ({ onChange={onChange} onValidationChange={onValidationChange} integrationName={integrationName} + connectorKey={connectorKey} editingSecrets={editingSecrets} setEditingSecrets={setEditingSecrets} /> diff --git a/src/modules/integration-picker/types.ts b/src/modules/integration-picker/types.ts index bf7543c..cdbc788 100644 --- a/src/modules/integration-picker/types.ts +++ b/src/modules/integration-picker/types.ts @@ -17,6 +17,31 @@ export interface HubData { events_encoded_context?: string; } +// V2/legacy TS connectors — always discriminated by the required `type` on the wire; message field is `error` +export interface LegacyFieldValidation { + type: 'html-pattern' | 'domain'; + pattern: string; + error?: string; + format?: never; + errorMessage?: never; +} + +// Local copy of the format names from `@stackone/core`'s `InputFormat` (connect repo, +// `packages/core/src/connector/types.ts`) — the hub deliberately carries no @stackone +// package dependencies for this feature; keep in sync when a format is added. The +// FORMAT_PATTERNS copy in utils/zodSchema.ts and the vector check in +// scripts/check-format-vectors.ts guard the regexes themselves. +export type FormatName = 'email' | 'url' | 'uuid' | 'date' | 'datetime' | 'uri'; + +// Falcon connectors — no `type`; exactly one of pattern/format is set (XOR), message +// field is `errorMessage`. Local copy of `AuthenticationFieldValidation` from +// `@stackone/core` (connect repo) — keep in sync if the authoring contract changes. +export type FalconFieldValidation = + | { type?: never; error?: never; pattern: string; format?: never; errorMessage?: string } + | { type?: never; error?: never; format: FormatName; pattern?: never; errorMessage?: string }; + +export type FieldValidation = LegacyFieldValidation | FalconFieldValidation; + export interface ConnectorConfigField { type?: 'text' | 'password' | 'number' | 'select' | 'text_area'; label: string; @@ -37,11 +62,7 @@ export interface ConnectorConfigField { }; value?: string | number; condition?: string; - validation?: { - type: 'html-pattern' | 'domain'; - pattern: string; - error?: string; - }; + validation?: FieldValidation; display?: boolean; } diff --git a/src/modules/integration-picker/utils/zodSchema.test.ts b/src/modules/integration-picker/utils/zodSchema.test.ts new file mode 100644 index 0000000..3ab2ce4 --- /dev/null +++ b/src/modules/integration-picker/utils/zodSchema.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from 'vitest'; +import type { ConnectorConfigField, FieldValidation } from '../types'; +import { createFormSchema } from './zodSchema'; + +// Minimal factory: builds a single-field connector config. Only the properties the +// Zod builder reads (`key`, `label`, `type`, `required`, `validation`) actually matter; +// the rest satisfy the `ConnectorConfigField` shape. +function field(overrides: Partial & { validation?: FieldValidation }) { + return { + key: 'field', + label: 'Field', + type: 'text', + required: false, + readOnly: false, + secret: false, + placeholder: '', + ...overrides, + } satisfies ConnectorConfigField; +} + +describe('createFormSchema — Falcon resolver', () => { + it('accepts an empty value on an OPTIONAL Falcon (pattern) field', () => { + const schema = createFormSchema([ + field({ required: false, validation: { pattern: '^[a-z]+$' } }), + ]); + + const result = schema.safeParse({ field: '' }); + + expect(result.success).toBe(true); + }); + + it('rejects an empty value on a REQUIRED Falcon field with the required message, not the format message', () => { + const schema = createFormSchema([ + field({ label: 'API Key', required: true, validation: { pattern: '^[a-z]+$' } }), + ]); + + const result = schema.safeParse({ field: '' }); + + expect(result.success).toBe(false); + if (!result.success) { + const message = result.error.issues[0].message; + expect(message).toBe('API Key is required'); + expect(message).not.toBe('API Key format is invalid'); + } + }); + + it('rejects a non-empty value violating a pattern with the custom errorMessage when provided', () => { + const schema = createFormSchema([ + field({ + validation: { pattern: '^[a-z]+$', errorMessage: 'Only lowercase letters allowed' }, + }), + ]); + + const result = schema.safeParse({ field: 'ABC123' }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe('Only lowercase letters allowed'); + } + }); + + it('rejects a non-empty pattern violation with the generated fallback "{Label} format is invalid"', () => { + const schema = createFormSchema([ + field({ label: 'Subdomain', validation: { pattern: '^[a-z]+$' } }), + ]); + + const result = schema.safeParse({ field: 'ABC123' }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe('Subdomain format is invalid'); + } + }); + + it('rejects a format: url violation with no errorMessage using "Must be a valid url"', () => { + const schema = createFormSchema([field({ validation: { format: 'url' } })]); + + const result = schema.safeParse({ field: 'not a url' }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe('Must be a valid url'); + } + }); + + it('accepts valid values for format: email, uuid and date', () => { + const emailSchema = createFormSchema([field({ validation: { format: 'email' } })]); + const uuidSchema = createFormSchema([field({ validation: { format: 'uuid' } })]); + const dateSchema = createFormSchema([field({ validation: { format: 'date' } })]); + + expect(emailSchema.safeParse({ field: 'user@example.com' }).success).toBe(true); + expect( + uuidSchema.safeParse({ field: '123e4567-e89b-12d3-a456-426614174000' }).success, + ).toBe(true); + expect(dateSchema.safeParse({ field: '2026-07-29' }).success).toBe(true); + }); +}); + +describe('createFormSchema — Legacy resolver', () => { + it('rejects an invalid value on an OPTIONAL legacy html-pattern field with the error message when provided', () => { + const schema = createFormSchema([ + field({ + required: false, + validation: { type: 'html-pattern', pattern: '^[0-9]+$', error: 'Digits only' }, + }), + ]); + + const result = schema.safeParse({ field: 'abc' }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe('Digits only'); + } + }); + + it('rejects an invalid legacy html-pattern value with the coded fallback when error is omitted', () => { + // NOTE: the code's fallback is `Please match the required format: ${pattern}`, + // NOT the RFC-worded "{Label} is invalid". Asserting the real string. + const schema = createFormSchema([ + field({ + required: false, + validation: { type: 'html-pattern', pattern: '^[0-9]+$' }, + }), + ]); + + const result = schema.safeParse({ field: 'abc' }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe( + 'Please match the required format: ^[0-9]+$', + ); + } + }); + + it('applies the legacy domain ".com" quirk: value must contain "{pattern}.com"', () => { + // resolveLegacyRule wraps a `domain` rule as `.*${pattern}\.com.*`, so the value + // must contain "acme.com" somewhere. A bare "acme" (no ".com") fails; a full + // "https://acme.com/path" passes. + const schema = createFormSchema([ + field({ required: false, validation: { type: 'domain', pattern: 'acme' } }), + ]); + + expect(schema.safeParse({ field: 'acme' }).success).toBe(false); + expect(schema.safeParse({ field: 'https://acme.com/login' }).success).toBe(true); + + const failed = schema.safeParse({ field: 'acme' }); + if (!failed.success) { + expect(failed.error.issues[0].message).toBe('Please enter a valid acme.com domain'); + } + }); + + it('routes on `type:` presence — same value passes as Falcon pattern but fails as legacy', () => { + // Value "acme" satisfies the Falcon pattern ^[a-z]+$ (no `type`), but the legacy + // `domain` rule (has `type`) rewrites the pattern to require ".com", so it fails. + // Demonstrates the discriminated-union routing in isLegacyValidation. + const falconSchema = createFormSchema([field({ validation: { pattern: '^[a-z]+$' } })]); + const legacySchema = createFormSchema([ + field({ validation: { type: 'domain', pattern: 'acme' } }), + ]); + + expect(falconSchema.safeParse({ field: 'acme' }).success).toBe(true); + expect(legacySchema.safeParse({ field: 'acme' }).success).toBe(false); + }); +}); + +describe('createFormSchema — robustness', () => { + it('accepts a saved-secret placeholder on a required field without running the rule', () => { + // Reconnect flow: the field is pre-filled with the redacted sentinel, not a value + // the customer typed. It must not fail validation (which would gate the Connect + // button), even against a strict pattern on a required field. + const schema = createFormSchema([ + field({ required: true, secret: true, validation: { format: 'email' } }), + ]); + + const result = schema.safeParse({ field: '__secretvalue:**redacted**abcd' }); + + expect(result.success).toBe(true); + }); + + it('leaves a field unvalidated (fail-open) when the format is unrecognised', () => { + const schema = createFormSchema([field({ validation: { format: 'hostname' as never } })]); + + expect(schema.safeParse({ field: 'literally anything' }).success).toBe(true); + }); + + it('degrades an uncompilable pattern to no rule instead of throwing during schema build', () => { + // createFormSchema runs inside a render useMemo — an uncompilable pattern that threw + // would take down the whole hub via the error boundary. Treat it as "no rule". + expect(() => createFormSchema([field({ validation: { pattern: '[' } })])).not.toThrow(); + + const schema = createFormSchema([field({ validation: { pattern: '[' } })]); + expect(schema.safeParse({ field: 'anything' }).success).toBe(true); + }); + + it('accepts the widened datetime offsets synced from @stackone/core', () => { + const schema = createFormSchema([field({ validation: { format: 'datetime' } })]); + + for (const value of [ + '2026-07-06T10:30:00z', + '2026-07-06T10:30:00+0100', + '2026-07-06T10:30:00+01', + ]) { + expect(schema.safeParse({ field: value }).success, value).toBe(true); + } + }); +}); diff --git a/src/modules/integration-picker/utils/zodSchema.ts b/src/modules/integration-picker/utils/zodSchema.ts index 6e399da..68e6ae2 100644 --- a/src/modules/integration-picker/utils/zodSchema.ts +++ b/src/modules/integration-picker/utils/zodSchema.ts @@ -1,7 +1,162 @@ import { z } from 'zod'; -import { ConnectorConfigField } from '../types'; +import { + ConnectorConfigField, + FalconFieldValidation, + FieldValidation, + FormatName, + LegacyFieldValidation, +} from '../types'; +import { isSecretPlaceholder } from './secretPlaceholder'; -function createFieldSchema(field: ConnectorConfigField): z.ZodTypeAny { +// Local copy of the canonical `FORMAT_PATTERNS` registry from `@stackone/core` +// (connect repo, `packages/core/src/connector/formatPatterns.ts`) — the hub +// deliberately carries no @stackone package dependencies for this feature. Keep in +// sync when a format changes; `scripts/check-format-vectors.ts` (run via `npm test`) +// asserts this copy against the canonical accept/reject vectors so a drifted copy +// fails CI. Exported for that script. +export const FORMAT_PATTERNS: Record = { + email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, + url: /^https?:\/\/[^\s/?#]+\S*$/, + uri: /^[a-zA-Z][a-zA-Z0-9+.-]*:.+$/, + uuid: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + date: /^\d{4}-\d{2}-\d{2}$/, + datetime: /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}(?::?\d{2})?)?$/, +}; + +interface ValidationRule { + pattern: RegExp; + errorMessage: string; +} + +function isLegacyValidation(validation: FieldValidation): validation is LegacyFieldValidation { + return validation.type !== undefined; +} + +// Compile a pattern, degrading to null (no rule) on an invalid regex rather than throwing. +// `createFormSchema` runs inside a render `useMemo`, so an uncompilable pattern would +// otherwise throw during render and trip the error boundary — replacing the whole hub and +// making the connector unlinkable. connect-sdk's build-time compile+ReDoS lint covers the +// `connectors` repo, but not the legacy TS path or whatever a direct-API surface returns +// (D1), nor the ReDoS blind spot (overlapping alternation) — so guard here too. +function compileRegex(source: string): RegExp | null { + try { + return new RegExp(source); + } catch { + return null; + } +} + +// V2/legacy TS connectors — behaviour preserved as-is, delete wholesale when V2 retires +function resolveLegacyRule(validation: LegacyFieldValidation): ValidationRule | null { + if (validation.type === 'html-pattern') { + const pattern = compileRegex(validation.pattern); + if (!pattern) return null; + return { + pattern, + errorMessage: + validation.error || `Please match the required format: ${validation.pattern}`, + }; + } + + if (validation.type === 'domain') { + const pattern = compileRegex(`.*${validation.pattern}\\.com.*`); + if (!pattern) return null; + return { + pattern, + errorMessage: + validation.error || `Please enter a valid ${validation.pattern}.com domain`, + }; + } + + return null; +} + +function resolveFalconRule( + validation: FalconFieldValidation, + label: string, +): ValidationRule | null { + if (validation.format) { + const pattern = FORMAT_PATTERNS[validation.format]; + if (!pattern) { + // Unknown format: connect-sdk derives its `format` enum from the canonical + // registry keys, so a format it accepts is missing here — this copy has drifted + // from `@stackone/core`. Fail open (failing closed would lock customers out on a + // hub-version skew) but loudly, since the field then renders unvalidated on the + // only enforcement layer. `scripts/check-format-vectors.ts` should catch this in + // CI; this warns at runtime if a drift ever reaches a customer. + console.warn( + `[stackone-hub] no pattern for format "${validation.format}" — field validation skipped; hub FORMAT_PATTERNS has drifted from @stackone/core`, + ); + return null; + } + return { + pattern, + errorMessage: validation.errorMessage || `Must be a valid ${validation.format}`, + }; + } + + if (validation.pattern) { + const pattern = compileRegex(validation.pattern); + if (!pattern) return null; + return { + pattern, + errorMessage: validation.errorMessage || `${label} format is invalid`, + }; + } + + return null; +} + +function resolveValidationRule(field: ConnectorConfigField): ValidationRule | null { + if (!field.validation) return null; + + return isLegacyValidation(field.validation) + ? resolveLegacyRule(field.validation) + : resolveFalconRule(field.validation, field.label); +} + +type RecordValidationFailure = (field: ConnectorConfigField, validation: FieldValidation) => void; + +// RFC step 9 (client half): the hub is a customer-embedded package with no analytics +// dependency, so validation failures are surfaced as a DOM CustomEvent — count-only +// (connector key + field key + rule kind, never the value; values may be +// credentials). Hosts or StackOne scripts can listen via +// window.addEventListener('stackone-hub:field-validation-failed', ...). +// +// The form re-validates on every keystroke (mode: 'onTouched' + default onChange +// reValidate), so a recorder bound to one schema build dispatches at most once per +// field for the life of that schema — a "this field failed at least once" friction +// signal, not one event per keystroke. No-op outside the browser (e.g. the npm-test +// vector check). +function createValidationFailureRecorder(connector?: string): RecordValidationFailure { + const firedFields = new Set(); + + return (field, validation) => { + if (typeof window === 'undefined' || firedFields.has(field.key)) return; + firedFields.add(field.key); + + const format = isLegacyValidation(validation) ? undefined : validation.format; + window.dispatchEvent( + new CustomEvent('stackone-hub:field-validation-failed', { + detail: { + ...(connector ? { connector } : {}), + field: field.key, + ruleKind: isLegacyValidation(validation) + ? 'legacy' + : format + ? 'format' + : 'pattern', + ...(format ? { format } : {}), + }, + }), + ); + }; +} + +function createFieldSchema( + field: ConnectorConfigField, + recordFailure: RecordValidationFailure, +): z.ZodTypeAny { let schema: z.ZodString = z.string(); if (field.required) { @@ -18,30 +173,30 @@ function createFieldSchema(field: ConnectorConfigField): z.ZodTypeAny { } } - if (field.validation) { - if (field.validation.type === 'html-pattern') { - const pattern = new RegExp(field.validation.pattern); - const errorMessage = - field.validation.error || - `Please match the required format: ${field.validation.pattern}`; - - if (field.required) { - schema = schema.regex(pattern, errorMessage); - } else { - return z.string().refine((val) => val === '' || pattern.test(val), errorMessage); - } - } else if (field.validation.type === 'domain') { - const pattern = new RegExp(`.*${field.validation.pattern}\\.com.*`); - const errorMessage = - field.validation.error || - `Please enter a valid ${field.validation.pattern}.com domain`; - - if (field.required) { - schema = schema.regex(pattern, errorMessage); - } else { - return z.string().refine((val) => val === '' || pattern.test(val), errorMessage); + const validation = field.validation; + const rule = resolveValidationRule(field); + if (rule && validation) { + const testWithMetric = (val: string) => { + // A saved secret is pre-filled as the redacted sentinel (`__secretvalue:**…`), + // not the real value the customer typed. RHF validates `defaultValues` eagerly, + // so without this guard the sentinel would fail the rule before the user touches + // anything — blocking reconnect (gating the Connect button) and emitting a + // failure event for an untouched field. Treat it as valid. + if (isSecretPlaceholder(val)) return true; + // The `&& val` guard is load-bearing: zod 4 accumulates all checks (it does not + // short-circuit on `.min(1)`), so this predicate runs on empty values too. Empty + // is a "required" failure, not a format failure — without `&& val` every + // untouched required field would emit a spurious event. + const ok = rule.pattern.test(val); + if (!ok && val) { + recordFailure(field, validation); } + return ok; + }; + if (field.required) { + return schema.refine((val) => testWithMetric(val), rule.errorMessage); } + return z.string().refine((val) => val === '' || testWithMetric(val), rule.errorMessage); } if (!field.required) { @@ -51,11 +206,15 @@ function createFieldSchema(field: ConnectorConfigField): z.ZodTypeAny { return schema; } -export function createFormSchema(fields: ConnectorConfigField[]) { +export function createFormSchema(fields: ConnectorConfigField[], connector?: string) { const schemaShape: Record = {}; + // One recorder per schema build so the failure event dedupes per field for the life + // of this schema instead of firing on every keystroke. + const recordFailure = createValidationFailureRecorder(connector); + for (const field of fields) { - schemaShape[field.key] = createFieldSchema(field); + schemaShape[field.key] = createFieldSchema(field, recordFailure); } return z.object(schemaShape);