From f8ac050c4c8840af8efddeca129d29e8b14a5d29 Mon Sep 17 00:00:00 2001 From: Chandrajeet Singh Date: Wed, 22 Jul 2026 19:41:22 +0530 Subject: [PATCH 01/10] feat(eng-823): Setup and config field validation --- package.json | 2 +- scripts/check-format-vectors.ts | 85 +++++++++ src/modules/integration-picker/types.ts | 31 +++- .../integration-picker/utils/zodSchema.ts | 166 +++++++++++++++--- 4 files changed, 253 insertions(+), 31 deletions(-) create mode 100644 scripts/check-format-vectors.ts diff --git a/package.json b/package.json index 4a88ee0..f353acc 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..237eb3f --- /dev/null +++ b/scripts/check-format-vectors.ts @@ -0,0 +1,85 @@ +/** + * 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'], + rejects: [ + 'example.com', + 'ftp://host', + '', + 'https://api.example.com extra text', + 'https://foo bar/baz', + ], + }, + 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', + ], + 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}"`); + } + } +} + +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/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.ts b/src/modules/integration-picker/utils/zodSchema.ts index 6e399da..a6fdbba 100644 --- a/src/modules/integration-picker/utils/zodSchema.ts +++ b/src/modules/integration-picker/utils/zodSchema.ts @@ -1,7 +1,127 @@ import { z } from 'zod'; -import { ConnectorConfigField } from '../types'; +import { + ConnectorConfigField, + FalconFieldValidation, + FieldValidation, + FormatName, + LegacyFieldValidation, +} from '../types'; -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+$/, + 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+)?(?:Z|[+-]\d{2}:\d{2})?$/, +}; + +interface ValidationRule { + pattern: RegExp; + errorMessage: string; +} + +function isLegacyValidation(validation: FieldValidation): validation is LegacyFieldValidation { + return validation.type !== undefined; +} + +// V2/legacy TS connectors — behaviour preserved as-is, delete wholesale when V2 retires +function resolveLegacyRule(validation: LegacyFieldValidation): ValidationRule | null { + if (validation.type === 'html-pattern') { + return { + pattern: new RegExp(validation.pattern), + errorMessage: + validation.error || `Please match the required format: ${validation.pattern}`, + }; + } + + if (validation.type === 'domain') { + return { + pattern: new RegExp(`.*${validation.pattern}\\.com.*`), + errorMessage: + validation.error || `Please enter a valid ${validation.pattern}.com domain`, + }; + } + + return null; +} + +function resolveFalconRule( + validation: FalconFieldValidation, + label: string, +): ValidationRule | null { + if (validation.format && FORMAT_PATTERNS[validation.format]) { + return { + pattern: FORMAT_PATTERNS[validation.format], + errorMessage: validation.errorMessage || `Must be a valid ${validation.format}`, + }; + } + + if (validation.pattern) { + return { + pattern: new RegExp(validation.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 +// (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(): 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: { + 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 +138,22 @@ 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) { + // Only record a non-empty value that failed — empty is "required", not a format + // failure (the optional branch below short-circuits empty before testing). + const testWithMetric = (val: string) => { + 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) { @@ -54,8 +166,12 @@ function createFieldSchema(field: ConnectorConfigField): z.ZodTypeAny { export function createFormSchema(fields: ConnectorConfigField[]) { 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(); + for (const field of fields) { - schemaShape[field.key] = createFieldSchema(field); + schemaShape[field.key] = createFieldSchema(field, recordFailure); } return z.object(schemaShape); From 56aa5c416c4061a2cd810a2aae1ee580b1a3391d Mon Sep 17 00:00:00 2001 From: Chandrajeet Singh Date: Thu, 30 Jul 2026 17:57:41 +0530 Subject: [PATCH 02/10] fix(eng-823): fix change requests --- .github/workflows/node-ci.yml | 4 +- scripts/check-format-vectors.ts | 13 ++ .../components/IntegrationFields.tsx | 4 +- .../components/IntegrationPickerContent.tsx | 1 + .../components/views/IntegrationFormView.tsx | 3 + .../utils/zodSchema.test.ts | 207 ++++++++++++++++++ .../integration-picker/utils/zodSchema.ts | 69 ++++-- 7 files changed, 286 insertions(+), 15 deletions(-) create mode 100644 src/modules/integration-picker/utils/zodSchema.test.ts 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/scripts/check-format-vectors.ts b/scripts/check-format-vectors.ts index 237eb3f..dffa5eb 100644 --- a/scripts/check-format-vectors.ts +++ b/scripts/check-format-vectors.ts @@ -43,6 +43,9 @@ const FORMAT_PATTERN_TEST_VECTORS: Record 0) { console.error(`${failures} format vector failure(s)`); process.exit(1); 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/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 a6fdbba..4cbbc35 100644 --- a/src/modules/integration-picker/utils/zodSchema.ts +++ b/src/modules/integration-picker/utils/zodSchema.ts @@ -6,6 +6,7 @@ import { FormatName, LegacyFieldValidation, } from '../types'; +import { isSecretPlaceholder } from './secretPlaceholder'; // Local copy of the canonical `FORMAT_PATTERNS` registry from `@stackone/core` // (connect repo, `packages/core/src/connector/formatPatterns.ts`) — the hub @@ -19,7 +20,7 @@ export const FORMAT_PATTERNS: Record = { 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+)?(?:Z|[+-]\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 { @@ -31,19 +32,37 @@ function isLegacyValidation(validation: FieldValidation): validation is LegacyFi 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: new RegExp(validation.pattern), + 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: new RegExp(`.*${validation.pattern}\\.com.*`), + pattern, errorMessage: validation.error || `Please enter a valid ${validation.pattern}.com domain`, }; @@ -56,16 +75,31 @@ function resolveFalconRule( validation: FalconFieldValidation, label: string, ): ValidationRule | null { - if (validation.format && FORMAT_PATTERNS[validation.format]) { + 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: FORMAT_PATTERNS[validation.format], + pattern, errorMessage: validation.errorMessage || `Must be a valid ${validation.format}`, }; } if (validation.pattern) { + const pattern = compileRegex(validation.pattern); + if (!pattern) return null; return { - pattern: new RegExp(validation.pattern), + pattern, errorMessage: validation.errorMessage || `${label} format is invalid`, }; } @@ -85,8 +119,8 @@ type RecordValidationFailure = (field: ConnectorConfigField, validation: FieldVa // 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 -// (field key + rule kind, never the value; values may be credentials). Hosts or -// StackOne scripts can listen via +// (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 @@ -94,7 +128,7 @@ type RecordValidationFailure = (field: ConnectorConfigField, validation: FieldVa // 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(): RecordValidationFailure { +function createValidationFailureRecorder(connector?: string): RecordValidationFailure { const firedFields = new Set(); return (field, validation) => { @@ -105,6 +139,7 @@ function createValidationFailureRecorder(): RecordValidationFailure { window.dispatchEvent( new CustomEvent('stackone-hub:field-validation-failed', { detail: { + ...(connector ? { connector } : {}), field: field.key, ruleKind: isLegacyValidation(validation) ? 'legacy' @@ -141,9 +176,17 @@ function createFieldSchema( const validation = field.validation; const rule = resolveValidationRule(field); if (rule && validation) { - // Only record a non-empty value that failed — empty is "required", not a format - // failure (the optional branch below short-circuits empty before testing). 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); @@ -163,12 +206,12 @@ function createFieldSchema( 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(); + const recordFailure = createValidationFailureRecorder(connector); for (const field of fields) { schemaShape[field.key] = createFieldSchema(field, recordFailure); From 0b701b363dd53629088744f78c8e7a8f15af1427 Mon Sep 17 00:00:00 2001 From: Chandrajeet Singh Date: Mon, 3 Aug 2026 16:02:53 +0530 Subject: [PATCH 03/10] fix(eng-823): tighten url format pattern (reject degenerate hosts) Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/check-format-vectors.ts | 13 ++++++++++++- src/modules/integration-picker/utils/zodSchema.ts | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/scripts/check-format-vectors.ts b/scripts/check-format-vectors.ts index dffa5eb..73fe35e 100644 --- a/scripts/check-format-vectors.ts +++ b/scripts/check-format-vectors.ts @@ -16,13 +16,24 @@ const FORMAT_PATTERN_TEST_VECTORS: Record = { email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, - url: /^https?:\/\/\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}$/, From 8e3708e854e25bfcf6f41a9ae65bcc75a5d3ff78 Mon Sep 17 00:00:00 2001 From: chandrajeet Date: Mon, 10 Aug 2026 15:34:55 +0530 Subject: [PATCH 04/10] fix(eng-823): recorder outlives schema rebuilds; run vitest in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hoist the failure recorder out of createFormSchema into IntegrationFields (useMemo keyed on connectorKey) and pass it in: the schema is rebuilt on every keystroke (watch -> onChange(formData) -> new fields identity), so a recorder owned by the build reset its per-field dedupe each rebuild and dispatched one event per keystroke. Mirrors the unified-cloud DynamicForm and embedded-widget wiring. - Regression tests pin the contract: one event per field across schema rebuilds, and the count-only event detail (connector + field + ruleKind, never the value). - Declare vitest and chain `vitest run` into npm test — the spec file previously never executed anywhere (npm test only ran the vector check, and vitest was not a declared dependency). - Note on ConnectorConfigField.validation that core's select-branch `validation?: never` constraint does not survive this flat copy (build-time rejection keeps exposure nil). Co-Authored-By: Claude Fable 5 --- package-lock.json | 1178 ++++++++++++++++- package.json | 5 +- .../components/IntegrationFields.tsx | 15 +- src/modules/integration-picker/types.ts | 5 + .../utils/zodSchema.test.ts | 42 +- .../integration-picker/utils/zodSchema.ts | 35 +- 6 files changed, 1201 insertions(+), 79 deletions(-) diff --git a/package-lock.json b/package-lock.json index 50e8397..15e13d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,8 @@ "rollup-plugin-peer-deps-external": "^2.2.4", "rollup-plugin-postcss": "^4.0.2", "tslib": "^2.8.1", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "vitest": "^4.1.10" }, "peerDependencies": { "@hookform/resolvers": "^5.2.2", @@ -280,10 +281,11 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.29", @@ -330,6 +332,279 @@ "node": ">= 8" } }, + "node_modules/@oxc-project/types": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/plugin-commonjs": { "version": "28.0.3", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.3.tgz", @@ -851,6 +1126,13 @@ "react-hook-form": "7.60.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@standard-schema/utils": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", @@ -908,6 +1190,24 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -990,6 +1290,92 @@ "react-dom": ">=18.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.14.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", @@ -1017,6 +1403,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -1105,6 +1501,16 @@ } ] }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1209,6 +1615,13 @@ "source-map": "^0.6.1" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/css-declaration-sorter": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.4.1.tgz", @@ -1418,6 +1831,16 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dom-serializer": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", @@ -1488,6 +1911,13 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1509,6 +1939,16 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "dev": true }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -1548,10 +1988,14 @@ } }, "node_modules/fdir": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", - "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -1838,63 +2282,336 @@ "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", "license": "MIT", "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-3.0.0.tgz", + "integrity": "sha512-kyiNFFLU0Ampr6SDZitD/DwUo4Zs1nSdnygUBqsu3LooL00Qvb5j+UnvApUn/TTj1J3OuE6BTdQ5rudKmU2ZaA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/is-module": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", - "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", - "dev": true - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=0.12.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/is-path-cwd": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-3.0.0.tgz", - "integrity": "sha512-kyiNFFLU0Ampr6SDZitD/DwUo4Zs1nSdnygUBqsu3LooL00Qvb5j+UnvApUn/TTj1J3OuE6BTdQ5rudKmU2ZaA==", + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/is-path-inside": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", - "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=12" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/is-reference": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", - "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], "dev": true, - "dependencies": { - "@types/estree": "*" + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" - }, "node_modules/lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", @@ -1958,12 +2675,13 @@ } }, "node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, + "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/marked": { @@ -2032,9 +2750,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -2043,7 +2761,6 @@ } ], "license": "MIT", - "peer": true, "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -2081,6 +2798,20 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/p-finally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", @@ -2173,6 +2904,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2180,9 +2918,9 @@ "dev": true }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -2205,9 +2943,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -2224,9 +2962,8 @@ } ], "license": "MIT", - "peer": true, "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2955,6 +3692,39 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.143.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" + } + }, "node_modules/rollup": { "version": "4.61.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.0.tgz", @@ -3146,6 +3916,13 @@ "node": ">=20.0.0" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/slash": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", @@ -3178,7 +3955,6 @@ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3210,6 +3986,20 @@ "deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility", "dev": true }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/string-hash": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/string-hash/-/string-hash-1.1.3.tgz", @@ -3308,6 +4098,50 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3409,6 +4243,228 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yaml": { "version": "1.10.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", diff --git a/package.json b/package.json index e0904fd..ae06d51 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "./dist/webcomponent.js" ], "scripts": { - "test": "npx -y tsx scripts/check-format-vectors.ts", + "test": "npx -y tsx scripts/check-format-vectors.ts && vitest run", "build": "rollup -c", "verify:build": "node scripts/verify-build.mjs", "dev": "cd dev/vite && npm run dev", @@ -79,7 +79,8 @@ "rollup-plugin-peer-deps-external": "^2.2.4", "rollup-plugin-postcss": "^4.0.2", "tslib": "^2.8.1", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "vitest": "^4.1.10" }, "overrides": { "@xmldom/xmldom": "0.8.13", diff --git a/src/modules/integration-picker/components/IntegrationFields.tsx b/src/modules/integration-picker/components/IntegrationFields.tsx index c4e0f4b..bc562d2 100644 --- a/src/modules/integration-picker/components/IntegrationFields.tsx +++ b/src/modules/integration-picker/components/IntegrationFields.tsx @@ -22,7 +22,7 @@ import useDeepCompareEffect from 'use-deep-compare-effect'; import { AuthenticationNotice, ConnectorConfigField } from '../types'; import { partitionNotices } from '../utils/partitionNotices'; import { formatSecretPlaceholder, isSecretPlaceholder } from '../utils/secretPlaceholder'; -import { createFormSchema } from '../utils/zodSchema'; +import { createFormSchema, createValidationFailureRecorder } from '../utils/zodSchema'; const isInputField = (type: string | undefined): type is 'text' | 'number' | 'password' => { return type === 'text' || type === 'number' || type === 'password'; @@ -288,7 +288,18 @@ 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, connectorKey), [fields, connectorKey]); + + // One recorder for the life of this form session (re-created only when the + // connector changes), NOT per schema build. `fields` gets a new identity on every + // keystroke (watch → onChange(formData) → useIntegrationPicker's fields memo), so + // the schema below rebuilds per keystroke — a recorder owned by that memo would + // reset its per-field dedupe each rebuild and dispatch one event per keystroke. + // Mirrors the unified-cloud DynamicForm and embedded-widget wiring. + const recordFailure = useMemo( + () => createValidationFailureRecorder(connectorKey), + [connectorKey], + ); + const schema = useMemo(() => createFormSchema(fields, recordFailure), [fields, recordFailure]); const defaultValues = useMemo(() => { const initialData: Record = {}; diff --git a/src/modules/integration-picker/types.ts b/src/modules/integration-picker/types.ts index cdbc788..1a8dd30 100644 --- a/src/modules/integration-picker/types.ts +++ b/src/modules/integration-picker/types.ts @@ -62,6 +62,11 @@ export interface ConnectorConfigField { }; value?: string | number; condition?: string; + // Weaker than core's AuthenticationField, which puts `validation?: never` on the + // `select` branch (values already constrained by `options[]`) — this flat copy + // cannot express that, so a select field with `validation` would build a rule + // here. Exposure is nil in practice: connect-sdk rejects the combination at + // build time, before a config can reach the hub. validation?: FieldValidation; display?: boolean; } diff --git a/src/modules/integration-picker/utils/zodSchema.test.ts b/src/modules/integration-picker/utils/zodSchema.test.ts index 3ab2ce4..7abec7e 100644 --- a/src/modules/integration-picker/utils/zodSchema.test.ts +++ b/src/modules/integration-picker/utils/zodSchema.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { ConnectorConfigField, FieldValidation } from '../types'; -import { createFormSchema } from './zodSchema'; +import { createFormSchema, createValidationFailureRecorder } from './zodSchema'; // Minimal factory: builds a single-field connector config. Only the properties the // Zod builder reads (`key`, `label`, `type`, `required`, `validation`) actually matter; @@ -205,3 +205,41 @@ describe('createFormSchema — robustness', () => { } }); }); + +describe('createValidationFailureRecorder — lifetime', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('dispatches at most once per field across schema rebuilds when the recorder outlives the schema', () => { + const dispatchEvent = vi.fn(); + vi.stubGlobal('window', { dispatchEvent }); + + // The form rebuilds its schema on every keystroke (fields gets a new identity + // via watch → onChange(formData) → the fields memo), so the recorder is owned + // by the component and passed in. Two builds with failing parses simulate two + // keystrokes; the dedupe must survive the rebuild. + const recordFailure = createValidationFailureRecorder('workday'); + const fields = [field({ validation: { pattern: '^[a-z]+$' } })]; + createFormSchema(fields, recordFailure).safeParse({ field: 'BAD1' }); + createFormSchema(fields, recordFailure).safeParse({ field: 'BAD12' }); + + expect(dispatchEvent).toHaveBeenCalledTimes(1); + }); + + it('carries the connector, field and rule kind on the event detail (never the value)', () => { + const dispatchEvent = vi.fn(); + vi.stubGlobal('window', { dispatchEvent }); + + const recordFailure = createValidationFailureRecorder('workday'); + createFormSchema([field({ validation: { pattern: '^[a-z]+$' } })], recordFailure).safeParse( + { field: 'BAD1' }, + ); + + expect(dispatchEvent.mock.calls[0][0].detail).toEqual({ + connector: 'workday', + field: 'field', + ruleKind: 'pattern', + }); + }); +}); diff --git a/src/modules/integration-picker/utils/zodSchema.ts b/src/modules/integration-picker/utils/zodSchema.ts index 68e6ae2..790bfe9 100644 --- a/src/modules/integration-picker/utils/zodSchema.ts +++ b/src/modules/integration-picker/utils/zodSchema.ts @@ -115,7 +115,10 @@ function resolveValidationRule(field: ConnectorConfigField): ValidationRule | nu : resolveFalconRule(field.validation, field.label); } -type RecordValidationFailure = (field: ConnectorConfigField, validation: FieldValidation) => void; +export 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 @@ -123,12 +126,15 @@ type RecordValidationFailure = (field: ConnectorConfigField, validation: FieldVa // 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 { +// Lifetime: the recorder must be owned by the rendering component for the life of +// the form session (useMemo keyed on the connector) and passed into +// createFormSchema — NOT created per schema build. The schema does not survive a +// keystroke (keystroke → onChange(formData) → new `fields` identity in +// useIntegrationPicker → schema useMemo rebuilds), so a recorder owned by the +// schema would reset its per-field dedupe on every rebuild and dispatch one event +// per keystroke instead of at most once per field. No-op outside the browser +// (e.g. the npm-test vector check). +export function createValidationFailureRecorder(connector?: string): RecordValidationFailure { const firedFields = new Set(); return (field, validation) => { @@ -206,12 +212,17 @@ function createFieldSchema( return schema; } -export function createFormSchema(fields: ConnectorConfigField[], connector?: string) { - const schemaShape: Record = {}; +// `recordFailure` is owned by the caller and must outlive this schema (see +// createValidationFailureRecorder's lifetime note) — schemas are rebuilt per +// keystroke, so a recorder created here would count keystrokes, not fields. +// Defaults to a no-op for callers without telemetry (tests, the vector check). +const noopRecorder: RecordValidationFailure = () => undefined; - // 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); +export function createFormSchema( + fields: ConnectorConfigField[], + recordFailure: RecordValidationFailure = noopRecorder, +) { + const schemaShape: Record = {}; for (const field of fields) { schemaShape[field.key] = createFieldSchema(field, recordFailure); From 3224b95e51d559325ebcb74d097ba210e8d9698b Mon Sep 17 00:00:00 2001 From: chandrajeet Date: Mon, 10 Aug 2026 15:37:06 +0530 Subject: [PATCH 05/10] fix(eng-823): sync package-lock with npm 10 for vitest install npm 11 locally omitted vite's optional peer yaml@2 resolution that node-ci's npm 10 requires, so `npm ci` failed with "Missing: yaml@2.9.0 from lock file". Regenerated with npm@10.9.2 and verified with `npm ci --dry-run`. Co-Authored-By: Claude Fable 5 --- package-lock.json | 48 ++++++++++++++++++----------------------------- 1 file changed, 18 insertions(+), 30 deletions(-) diff --git a/package-lock.json b/package-lock.json index 15e13d0..3e49036 100644 --- a/package-lock.json +++ b/package-lock.json @@ -435,9 +435,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -455,9 +452,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -475,9 +469,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -495,9 +486,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -515,9 +503,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -535,9 +520,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2482,9 +2464,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2506,9 +2485,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2530,9 +2506,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2554,9 +2527,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4448,6 +4418,24 @@ } } }, + "node_modules/vitest/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", From f1fa8af0fff1b368bff2b893fab0675f6f427314 Mon Sep 17 00:00:00 2001 From: chandrajeet Date: Tue, 11 Aug 2026 16:08:27 +0530 Subject: [PATCH 06/10] fix(eng-823): sync email format pattern with canonical registry hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit connect#1304's round-2 review rewrote the canonical email pattern with a lookahead (the overlapping domain/TLD tail backtracks quadratically, and this copy runs per keystroke). Language-identical, so the vector conformance check cannot catch this drift — synced manually. Co-Authored-By: Claude Fable 5 --- src/modules/integration-picker/utils/zodSchema.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/modules/integration-picker/utils/zodSchema.ts b/src/modules/integration-picker/utils/zodSchema.ts index 790bfe9..607defe 100644 --- a/src/modules/integration-picker/utils/zodSchema.ts +++ b/src/modules/integration-picker/utils/zodSchema.ts @@ -15,7 +15,11 @@ import { isSecretPlaceholder } from './secretPlaceholder'; // 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@]+$/, + // The lookahead pins the required interior dot without the overlapping + // `[^\s@]+\.[^\s@]+` tail, whose mutual backtracking is quadratic on long + // non-matching input — and this pattern runs on every keystroke. + // Language-identical to the overlapping form (fuzz-verified in @stackone/core). + email: /^[^\s@]+@(?=[^\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, From 0750906ffdc97b5fd1772119051381b3585f3fa3 Mon Sep 17 00:00:00 2001 From: chandrajeet Date: Tue, 11 Aug 2026 16:36:23 +0530 Subject: [PATCH 07/10] fix(eng-823): close hub round-2 findings on regex safety and CI pinning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sync url (linear disjoint-boundary form) and uri (rejects trailing garbage) with the canonical registry — the email sync landed earlier but these two had drifted; add the uri reject vectors that would have caught it. - Port hasCatastrophicBacktrackingRisk from connect-sdk as a local copy and gate compileRegex on it: the try/catch only ever caught construction-time SyntaxErrors, while a stored `^(a+)+$` hung the tab 15s+ at match time. Fail open (rule skipped, no validation) with tests pinning both exponential classes return quickly. - Correct the compileRegex comment that claimed the guard covered the backtracking hazard it did not. - Pin tsx as a devDependency and drop `npx -y` from npm test, so CI resolves it from the lockfile under npm ci integrity checks instead of fetching it unpinned from the registry per run. Co-Authored-By: Claude Fable 5 --- package-lock.json | 504 ++++++++++++++++++ package.json | 3 +- scripts/check-format-vectors.ts | 8 +- .../integration-picker/utils/regexSafety.ts | 109 ++++ .../utils/zodSchema.test.ts | 32 ++ .../integration-picker/utils/zodSchema.ts | 25 +- 6 files changed, 671 insertions(+), 10 deletions(-) create mode 100644 src/modules/integration-picker/utils/regexSafety.ts diff --git a/package-lock.json b/package-lock.json index 3e49036..bacb1ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "rollup-plugin-peer-deps-external": "^2.2.4", "rollup-plugin-postcss": "^4.0.2", "tslib": "^2.8.1", + "tsx": "^4.23.12", "typescript": "^5.8.3", "vitest": "^4.1.10" }, @@ -239,6 +240,448 @@ "node": ">=14.21.3" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@hookform/resolvers": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", @@ -1900,6 +2343,48 @@ "dev": true, "license": "MIT" }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4130,6 +4615,25 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", diff --git a/package.json b/package.json index ae06d51..a888d43 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "./dist/webcomponent.js" ], "scripts": { - "test": "npx -y tsx scripts/check-format-vectors.ts && vitest run", + "test": "tsx scripts/check-format-vectors.ts && vitest run", "build": "rollup -c", "verify:build": "node scripts/verify-build.mjs", "dev": "cd dev/vite && npm run dev", @@ -79,6 +79,7 @@ "rollup-plugin-peer-deps-external": "^2.2.4", "rollup-plugin-postcss": "^4.0.2", "tslib": "^2.8.1", + "tsx": "^4.23.12", "typescript": "^5.8.3", "vitest": "^4.1.10" }, diff --git a/scripts/check-format-vectors.ts b/scripts/check-format-vectors.ts index 73fe35e..d9e2267 100644 --- a/scripts/check-format-vectors.ts +++ b/scripts/check-format-vectors.ts @@ -38,7 +38,13 @@ const FORMAT_PATTERN_TEST_VECTORS: Record { + const groups: { hasVariable: boolean; hasAlternation: boolean }[] = []; + let inClass = false; + + // Sticky flag: anchored match at lastIndex without slicing the source per check. + const braceQuantifier = /\{(\d+)(?:(,)(\d*))?\}/y; + + const quantifierAt = ( + index: number, + ): { length: number; variable: boolean; amplifying: boolean } | undefined => { + const char = source[index]; + if (char === '*' || char === '+') { + return { length: 1, variable: true, amplifying: true }; + } + if (char === '?') { + return { length: 1, variable: true, amplifying: false }; + } + if (char === '{') { + braceQuantifier.lastIndex = index; + const match = braceQuantifier.exec(source); + if (match) { + const min = Number(match[1]); + const max = match[2] === undefined ? min : match[3] ? Number(match[3]) : Infinity; + return { length: match[0].length, variable: max > min, amplifying: max >= 2 }; + } + } + return undefined; + }; + + for (let i = 0; i < source.length; i++) { + const char = source[i]; + if (char === '\\') { + i++; + continue; + } + if (inClass) { + if (char === ']') { + inClass = false; + } + continue; + } + if (char === '[') { + inClass = true; + continue; + } + if (char === '|') { + if (groups.length > 0) { + groups[groups.length - 1].hasAlternation = true; + } + continue; + } + if (char === '(') { + groups.push({ hasVariable: false, hasAlternation: false }); + // Skip a group prefix — `(?:`, `(?=`, `(?!`, `(?<=`, `(?`. The + // `?` here opens a special group, it is never a quantifier (nothing precedes + // it to repeat), so it must not mark the group as variable-width. Lookbehind + // (`(?<=` / `(?` body + // is plain chars that fall through harmlessly. + if (source[i + 1] === '?') { + const isLookbehind = + source[i + 2] === '<' && (source[i + 3] === '=' || source[i + 3] === '!'); + i += isLookbehind ? 2 : 1; + } + continue; + } + if (char === ')') { + const closed = groups.pop(); + const quantifier = quantifierAt(i + 1); + if (quantifier) { + if (quantifier.amplifying && (closed?.hasVariable || closed?.hasAlternation)) { + return true; + } + i += quantifier.length; + } + if (groups.length > 0) { + if (quantifier?.variable || closed?.hasVariable) { + groups[groups.length - 1].hasVariable = true; + } + if (closed?.hasAlternation) { + groups[groups.length - 1].hasAlternation = true; + } + } + continue; + } + const quantifier = quantifierAt(i); + if (quantifier) { + if (quantifier.variable && groups.length > 0) { + groups[groups.length - 1].hasVariable = true; + } + i += quantifier.length - 1; + } + } + + return false; +}; diff --git a/src/modules/integration-picker/utils/zodSchema.test.ts b/src/modules/integration-picker/utils/zodSchema.test.ts index 7abec7e..0812fd9 100644 --- a/src/modules/integration-picker/utils/zodSchema.test.ts +++ b/src/modules/integration-picker/utils/zodSchema.test.ts @@ -243,3 +243,35 @@ describe('createValidationFailureRecorder — lifetime', () => { }); }); }); + +describe('createFormSchema — ReDoS guard', () => { + it('treats a nested-quantifier pattern as no rule instead of hanging per keystroke', () => { + // `^(a+)+$` backtracks exponentially on a near-miss value; the lint copy in + // regexSafety.ts drops the rule (fail open) so the tab never hangs. + const schema = createFormSchema([field({ validation: { pattern: '^(a+)+$' } })]); + const start = Date.now(); + + const result = schema.safeParse({ field: `${'a'.repeat(60)}!` }); + + expect(result.success).toBe(true); + expect(Date.now() - start).toBeLessThan(1000); + }); + + it('treats a repeated-alternation pattern as no rule (the star-height blind spot)', () => { + const schema = createFormSchema([field({ validation: { pattern: '(a|a)+$' } })]); + const start = Date.now(); + + const result = schema.safeParse({ field: `${'a'.repeat(40)}!` }); + + expect(result.success).toBe(true); + expect(Date.now() - start).toBeLessThan(1000); + }); + + it('still applies a safe legacy html-pattern rule (guard is not over-broad)', () => { + const schema = createFormSchema([ + field({ validation: { type: 'html-pattern', pattern: '^[0-9]+$' } }), + ]); + + expect(schema.safeParse({ field: 'abc' }).success).toBe(false); + }); +}); diff --git a/src/modules/integration-picker/utils/zodSchema.ts b/src/modules/integration-picker/utils/zodSchema.ts index 607defe..415e210 100644 --- a/src/modules/integration-picker/utils/zodSchema.ts +++ b/src/modules/integration-picker/utils/zodSchema.ts @@ -6,6 +6,7 @@ import { FormatName, LegacyFieldValidation, } from '../types'; +import { hasCatastrophicBacktrackingRisk } from './regexSafety'; import { isSecretPlaceholder } from './secretPlaceholder'; // Local copy of the canonical `FORMAT_PATTERNS` registry from `@stackone/core` @@ -20,8 +21,11 @@ export const FORMAT_PATTERNS: Record = { // non-matching input — and this pattern runs on every keystroke. // Language-identical to the overlapping form (fuzz-verified in @stackone/core). email: /^[^\s@]+@(?=[^\s@]+\.[^\s@])[^\s@]+$/, - url: /^https?:\/\/[^\s/?#]+\S*$/, - uri: /^[a-zA-Z][a-zA-Z0-9+.-]*:.+$/, + // Host and path/query/fragment split on a disjoint `[/?#]` boundary so the engine + // can never backtrack between them — the overlapping `[^\s/?#]+\S*` form is + // quadratic on long non-matching input, and this pattern runs on every keystroke. + url: /^https?:\/\/[^\s/?#]+(?:[/?#]\S*)?$/, + uri: /^[a-zA-Z][a-zA-Z0-9+.-]*:\S+$/, 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})?)?$/, @@ -36,13 +40,18 @@ function isLegacyValidation(validation: FieldValidation): validation is LegacyFi 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. +// Compile a pattern, degrading to null (no rule) rather than breaking the form, for two +// distinct hazards: an uncompilable pattern would throw inside the render `useMemo` and +// trip the error boundary (whole hub replaced, connector unlinkable), and a +// catastrophic-backtracking pattern would hang the tab at match time — the rule runs on +// every keystroke, and only the compile guard could ever catch the first hazard, not the +// second. connect-sdk rejects both at connector build time, but that gate covers neither +// connectors built before it existed nor the legacy TS path, so re-guard both here. +// Fail open: a skipped rule degrades to today's no-validation behaviour. function compileRegex(source: string): RegExp | null { + if (hasCatastrophicBacktrackingRisk(source)) { + return null; + } try { return new RegExp(source); } catch { From 8b1e829a5c4b14b3119e1755535a16a971d90f89 Mon Sep 17 00:00:00 2001 From: chandrajeet Date: Wed, 12 Aug 2026 12:27:44 +0530 Subject: [PATCH 08/10] fix(eng-823): add pinned-source and live-canonical layers to format gate Extends check-format-vectors.ts from vector-only to three layers: pinned canonical regex sources (catches language-identical rewrites the vectors cannot see) and a live comparison against connect main's registry (catches an upstream format added after the snapshot), degrading loudly when the canonical file is unreachable. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/check-format-vectors.ts | 140 ++++++++++++++++-- .../integration-picker/utils/zodSchema.ts | 9 +- 2 files changed, 135 insertions(+), 14 deletions(-) diff --git a/scripts/check-format-vectors.ts b/scripts/check-format-vectors.ts index d9e2267..6f8fa01 100644 --- a/scripts/check-format-vectors.ts +++ b/scripts/check-format-vectors.ts @@ -1,15 +1,47 @@ /** - * 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`). + * Conformance gate for the hub's local FORMAT_PATTERNS copy, run via `npm test`. + * Three layers, each catching a different drift mode — be precise about what each + * can and cannot catch: * - * 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`. + * 1. Vectors (offline): every pattern passes the pinned accept/reject vectors, and + * every registry key has vectors. Catches semantic drift the vectors encode — + * but NOT a regression that keeps the accepted language identical (the round-1 + * quadratic `url` form passed every vector). + * 2. Pinned canonical sources (offline): every pattern's source+flags must equal + * the canonical regex text pinned below. Catches language-identical rewrites + * drifting from canonical — but the pin itself is a hub-local snapshot; bump it + * together with the registry copy when connect's registry changes. + * 3. Live canonical comparison (network): fetches connect main's actual + * `formatPatterns.ts` and compares key sets and sources. The only layer that + * catches an upstream format ADDED after this snapshot. Enforcing when the file + * is reachable; warns loudly (never silently passes) when it is not — e.g. + * before connect#1304 publishes the canonical file to main. + * + * Canonical sources: connect repo, `packages/core/src/connector/formatPatterns.ts` + * and `packages/core/src/connector/specs/formatPatterns.vectors.ts`. */ import { FORMAT_PATTERNS } from '../src/modules/integration-picker/utils/zodSchema'; +// Layer 2 pin: the canonical regex text, verbatim (source without delimiters, then +// flags). Snapshot of connect#1304 head — bump alongside the registry copy. +const CANONICAL_PATTERN_SOURCES: Record = { + email: { source: '^[^\\s@]+@(?=[^\\s@]+\\.[^\\s@])[^\\s@]+$', flags: '' }, + url: { source: '^https?:\\/\\/[^\\s/?#]+(?:[/?#]\\S*)?$', flags: '' }, + uri: { source: '^[a-zA-Z][a-zA-Z0-9+.-]*:\\S+$', flags: '' }, + uuid: { + source: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', + flags: 'i', + }, + date: { source: '^\\d{4}-\\d{2}-\\d{2}$', flags: '' }, + datetime: { + source: '^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:[Zz]|[+-]\\d{2}(?::?\\d{2})?)?$', + flags: '', + }, +}; + +const CANONICAL_REGISTRY_URL = + 'https://raw.githubusercontent.com/StackOneHQ/connect/main/packages/core/src/connector/formatPatterns.ts'; + const FORMAT_PATTERN_TEST_VECTORS: Record = { email: { accepts: ['john@example.com', 'a.b+tag@sub.domain.co'], @@ -76,6 +108,7 @@ const FORMAT_PATTERN_TEST_VECTORS: Record vector conformance. for (const [format, vectors] of Object.entries(FORMAT_PATTERN_TEST_VECTORS)) { const pattern = FORMAT_PATTERNS[format as keyof typeof FORMAT_PATTERNS]; if (!pattern) { @@ -107,9 +140,94 @@ for (const format of Object.keys(FORMAT_PATTERNS)) { } } -if (failures > 0) { - console.error(`${failures} format vector failure(s)`); - process.exit(1); +// Layer 2: pinned canonical sources — catches a rewrite that keeps the accepted +// language identical (invisible to vectors) but drifts from the canonical regex text. +for (const [format, canonical] of Object.entries(CANONICAL_PATTERN_SOURCES)) { + const pattern = FORMAT_PATTERNS[format as keyof typeof FORMAT_PATTERNS]; + if (!pattern) { + failures++; + console.error(`FAIL: registry is missing pinned canonical format "${format}"`); + continue; + } + if (pattern.source !== canonical.source || pattern.flags !== canonical.flags) { + failures++; + console.error( + `FAIL: ${format} source drifted from the pinned canonical\n local: /${pattern.source}/${pattern.flags}\n canonical: /${canonical.source}/${canonical.flags}`, + ); + } +} +for (const format of Object.keys(FORMAT_PATTERNS)) { + if (!CANONICAL_PATTERN_SOURCES[format]) { + failures++; + console.error(`FAIL: no pinned canonical source for registry format "${format}"`); + } } -console.log('All format vectors pass'); +// Layer 3: live comparison against connect main's actual registry — the only layer +// that can catch a format added upstream after the layer-2 snapshot. Enforcing when +// reachable; loud (never silent) when not. +const compareAgainstLiveCanonical = async (): Promise => { + let body: string; + try { + const response = await fetch(CANONICAL_REGISTRY_URL); + if (response.status === 404) { + console.warn( + 'WARN: canonical formatPatterns.ts not found on connect main (404) — the file ships with connect#1304; the live-canonical layer is inactive until it merges', + ); + return; + } + if (!response.ok) { + console.warn( + `WARN: could not fetch canonical registry (HTTP ${response.status}) — live-canonical layer skipped this run`, + ); + return; + } + body = await response.text(); + } catch (error) { + console.warn( + `WARN: could not fetch canonical registry (${error instanceof Error ? error.message : String(error)}) — live-canonical layer skipped this run`, + ); + return; + } + + // Extract `key: /source/flags,` entries from the canonical registry literal. A + // regex literal's source may contain unescaped `/` inside a character class + // (`[^\s/?#]`), so classes are matched as their own alternative. + const entries = new Map(); + const entryPattern = /^\s{4}(\w+): \/((?:[^/\\[\n]|\\.|\[(?:[^\]\\]|\\.)*\])+)\/([a-z]*),$/gm; + for (const match of body.matchAll(entryPattern)) { + entries.set(match[1], { source: match[2], flags: match[3] }); + } + if (entries.size === 0) { + console.warn( + 'WARN: fetched canonical registry but parsed no pattern entries — live-canonical layer needs its parser updated', + ); + return; + } + + for (const [format, canonical] of entries) { + const pattern = FORMAT_PATTERNS[format as keyof typeof FORMAT_PATTERNS]; + if (!pattern) { + failures++; + console.error( + `FAIL: connect main's registry has format "${format}" — missing from the hub copy (add the pattern, vectors and pinned source)`, + ); + continue; + } + if (pattern.source !== canonical.source || pattern.flags !== canonical.flags) { + failures++; + console.error( + `FAIL: ${format} drifted from connect main\n local: /${pattern.source}/${pattern.flags}\n canonical: /${canonical.source}/${canonical.flags}`, + ); + } + } + console.log(`Live canonical comparison ran against ${entries.size} upstream formats`); +}; + +compareAgainstLiveCanonical().then(() => { + if (failures > 0) { + console.error(`${failures} format conformance failure(s)`); + process.exit(1); + } + console.log('All format vectors and canonical-source checks pass'); +}); diff --git a/src/modules/integration-picker/utils/zodSchema.ts b/src/modules/integration-picker/utils/zodSchema.ts index 415e210..5aab852 100644 --- a/src/modules/integration-picker/utils/zodSchema.ts +++ b/src/modules/integration-picker/utils/zodSchema.ts @@ -12,9 +12,12 @@ import { isSecretPlaceholder } from './secretPlaceholder'; // 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. +// sync when a format changes. `scripts/check-format-vectors.ts` (run via `npm test`) +// gates this copy three ways: pinned accept/reject vectors, pinned canonical regex +// sources (catches language-identical rewrites the vectors cannot see), and — when +// reachable — connect main's live registry (the only check that catches an upstream +// format added after the pins). The pins are themselves hub-local snapshots: bump +// them together with this copy. Exported for that script. export const FORMAT_PATTERNS: Record = { // The lookahead pins the required interior dot without the overlapping // `[^\s@]+\.[^\s@]+` tail, whose mutual backtracking is quadratic on long From 732736eb13dbb710ee3ff1e761d2754a72819add Mon Sep 17 00:00:00 2001 From: chandrajeet Date: Fri, 14 Aug 2026 16:17:40 +0530 Subject: [PATCH 09/10] fix(eng-823): drop live-canonical layer; document manual upstream sync connect is a private repo, so an unauthenticated CI fetch of its canonical formatPatterns.ts 404s permanently. Remove the network Layer 3 and its regex parser, keep the two offline layers (vectors + pinned sources), and rewrite the headers to state that an upstream format ADD now needs a manual sync. Add trailing-text reject vectors to email/uuid/date to anchor the patterns. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/check-format-vectors.ts | 125 ++++++------------ .../integration-picker/utils/zodSchema.ts | 15 ++- 2 files changed, 45 insertions(+), 95 deletions(-) diff --git a/scripts/check-format-vectors.ts b/scripts/check-format-vectors.ts index 6f8fa01..113fba7 100644 --- a/scripts/check-format-vectors.ts +++ b/scripts/check-format-vectors.ts @@ -1,21 +1,22 @@ /** * Conformance gate for the hub's local FORMAT_PATTERNS copy, run via `npm test`. - * Three layers, each catching a different drift mode — be precise about what each - * can and cannot catch: + * Two offline layers — be precise about what each can and cannot catch: * - * 1. Vectors (offline): every pattern passes the pinned accept/reject vectors, and - * every registry key has vectors. Catches semantic drift the vectors encode — - * but NOT a regression that keeps the accepted language identical (the round-1 - * quadratic `url` form passed every vector). - * 2. Pinned canonical sources (offline): every pattern's source+flags must equal - * the canonical regex text pinned below. Catches language-identical rewrites - * drifting from canonical — but the pin itself is a hub-local snapshot; bump it - * together with the registry copy when connect's registry changes. - * 3. Live canonical comparison (network): fetches connect main's actual - * `formatPatterns.ts` and compares key sets and sources. The only layer that - * catches an upstream format ADDED after this snapshot. Enforcing when the file - * is reachable; warns loudly (never silently passes) when it is not — e.g. - * before connect#1304 publishes the canonical file to main. + * 1. Vectors: every pattern passes the pinned accept/reject vectors, and every + * registry key has vectors. Catches semantic drift the vectors encode — but NOT + * a regression that keeps the accepted language identical (the round-1 quadratic + * `url` form passed every vector). + * 2. Pinned canonical sources: every pattern's source+flags must equal the + * canonical regex text pinned below. Catches language-identical rewrites + * drifting from canonical. + * + * What NO layer here can catch: a format ADDED to connect's registry after these + * pins were taken. Both sides of every check are hub-local snapshots, and there is + * no live comparison — `StackOneHQ/connect` is private, so an unauthenticated fetch + * of the canonical file 404s permanently, and pulling private source into this + * public repo's CI is not an option. An upstream format addition therefore requires + * a MANUAL sync: bump the registry copy (utils/zodSchema.ts), these vectors, and + * the pinned sources together. * * Canonical sources: connect repo, `packages/core/src/connector/formatPatterns.ts` * and `packages/core/src/connector/specs/formatPatterns.vectors.ts`. @@ -39,13 +40,16 @@ const CANONICAL_PATTERN_SOURCES: Record = { email: { accepts: ['john@example.com', 'a.b+tag@sub.domain.co'], - rejects: ['not-an-email', 'a b@example.com', '@example.com', 'john@'], + rejects: [ + 'not-an-email', + 'a b@example.com', + '@example.com', + 'john@', + 'john@example.com extra text', + ], }, url: { accepts: [ @@ -80,11 +84,16 @@ const FORMAT_PATTERN_TEST_VECTORS: Record => { - let body: string; - try { - const response = await fetch(CANONICAL_REGISTRY_URL); - if (response.status === 404) { - console.warn( - 'WARN: canonical formatPatterns.ts not found on connect main (404) — the file ships with connect#1304; the live-canonical layer is inactive until it merges', - ); - return; - } - if (!response.ok) { - console.warn( - `WARN: could not fetch canonical registry (HTTP ${response.status}) — live-canonical layer skipped this run`, - ); - return; - } - body = await response.text(); - } catch (error) { - console.warn( - `WARN: could not fetch canonical registry (${error instanceof Error ? error.message : String(error)}) — live-canonical layer skipped this run`, - ); - return; - } - - // Extract `key: /source/flags,` entries from the canonical registry literal. A - // regex literal's source may contain unescaped `/` inside a character class - // (`[^\s/?#]`), so classes are matched as their own alternative. - const entries = new Map(); - const entryPattern = /^\s{4}(\w+): \/((?:[^/\\[\n]|\\.|\[(?:[^\]\\]|\\.)*\])+)\/([a-z]*),$/gm; - for (const match of body.matchAll(entryPattern)) { - entries.set(match[1], { source: match[2], flags: match[3] }); - } - if (entries.size === 0) { - console.warn( - 'WARN: fetched canonical registry but parsed no pattern entries — live-canonical layer needs its parser updated', - ); - return; - } - - for (const [format, canonical] of entries) { - const pattern = FORMAT_PATTERNS[format as keyof typeof FORMAT_PATTERNS]; - if (!pattern) { - failures++; - console.error( - `FAIL: connect main's registry has format "${format}" — missing from the hub copy (add the pattern, vectors and pinned source)`, - ); - continue; - } - if (pattern.source !== canonical.source || pattern.flags !== canonical.flags) { - failures++; - console.error( - `FAIL: ${format} drifted from connect main\n local: /${pattern.source}/${pattern.flags}\n canonical: /${canonical.source}/${canonical.flags}`, - ); - } - } - console.log(`Live canonical comparison ran against ${entries.size} upstream formats`); -}; +if (failures > 0) { + console.error(`${failures} format conformance failure(s)`); + process.exit(1); +} -compareAgainstLiveCanonical().then(() => { - if (failures > 0) { - console.error(`${failures} format conformance failure(s)`); - process.exit(1); - } - console.log('All format vectors and canonical-source checks pass'); -}); +console.log( + 'All format vectors and canonical-source checks pass (hub-local pins — an upstream format addition needs a manual sync, see header)', +); diff --git a/src/modules/integration-picker/utils/zodSchema.ts b/src/modules/integration-picker/utils/zodSchema.ts index 5aab852..83892e0 100644 --- a/src/modules/integration-picker/utils/zodSchema.ts +++ b/src/modules/integration-picker/utils/zodSchema.ts @@ -11,13 +11,14 @@ import { isSecretPlaceholder } from './secretPlaceholder'; // 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`) -// gates this copy three ways: pinned accept/reject vectors, pinned canonical regex -// sources (catches language-identical rewrites the vectors cannot see), and — when -// reachable — connect main's live registry (the only check that catches an upstream -// format added after the pins). The pins are themselves hub-local snapshots: bump -// them together with this copy. Exported for that script. +// deliberately carries no @stackone package dependencies for this feature. +// `scripts/check-format-vectors.ts` (run via `npm test`) gates this copy two ways: +// pinned accept/reject vectors, and pinned canonical regex sources (catches +// language-identical rewrites the vectors cannot see). Both pins are hub-local +// snapshots and there is no live comparison (connect is a private repo), so a format +// ADDED upstream is NOT caught automatically — sync this copy, the vectors and the +// pinned sources together, manually, when connect's registry changes. Exported for +// that script. export const FORMAT_PATTERNS: Record = { // The lookahead pins the required interior dot without the overlapping // `[^\s@]+\.[^\s@]+` tail, whose mutual backtracking is quadratic on long From a2d3606e9831be7be15a6ae321bd65bd17b05449 Mon Sep 17 00:00:00 2001 From: chandrajeet Date: Wed, 2 Sep 2026 17:32:53 +0530 Subject: [PATCH 10/10] fix(ENG-823): address review findings, keep connect as validation source of truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep-reviewed against connect's canonical schema (AUTHENTICATION_FIELD_SCHEMA, FORMAT_PATTERNS, regexSafety) and fixed the eng-review / Copilot / StuBehan findings: - Silent fail-open (bug-d862f45f): compileRegex now warns on both the ReDoS-flagged and uncompilable paths, not only unknown-format — the client is the only enforcement layer, so a dropped rule must never render a field unvalidated silently. - Quadratic ReDoS (bug-3d935788): the star-height lint catches exponential only; bound the input fed to author patterns (MAX_PATTERN_INPUT_LENGTH), the canonical docstring's own recommendation for the adjacent-unbounded-quantifier shape. Format patterns are canonical + linear and never capped. - Number secret placeholder (arch-179): admit the redacted sentinel on number fields so reconnect isn't gated; number carries no validation per the connector schema, so it returns before the rule section. - CI coverage (proc-039a): lint + typecheck now cover ./scripts (biome globs + tsc --noEmit). - Comment accuracy: schema rebuilds on connector/account-data change (not per keystroke); cite the 0/1695 select-validation count; the build-time select rejection is unmerged connect#1304, not connect main. - Tests for all three behaviours. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 6 +- scripts/verify-build.mjs | 74 +++++++++---------- .../components/IntegrationFields.tsx | 11 ++- src/modules/integration-picker/types.ts | 9 ++- .../integration-picker/utils/regexSafety.ts | 6 ++ .../utils/zodSchema.test.ts | 48 +++++++++++- .../integration-picker/utils/zodSchema.ts | 57 +++++++++++--- tsconfig.json | 2 +- 8 files changed, 146 insertions(+), 67 deletions(-) diff --git a/package.json b/package.json index a888d43..7aac0cb 100644 --- a/package.json +++ b/package.json @@ -43,9 +43,9 @@ "dev:angular": "cd dev/angular && npm run dev", "dev:angular:setup": "npm run build && cd dev/angular && npm install", "relay": "cd dev/vite && npm run relay", - "code:format": "biome format ./src ./dev", - "code:format:fix": "biome format --write ./src ./dev", - "lint": "biome lint --error-on-warnings ./src ./dev && biome check ./src ./dev", + "code:format": "biome format ./src ./dev ./scripts", + "code:format:fix": "biome format --write ./src ./dev ./scripts", + "lint": "biome lint --error-on-warnings ./src ./dev ./scripts && biome check ./src ./dev ./scripts && tsc --noEmit", "lint:fix": "biome lint --write ./src ./dev && biome check --write ./src ./dev", "publish-release": "npm publish --access=public" }, diff --git a/scripts/verify-build.mjs b/scripts/verify-build.mjs index 162e2fb..e19928b 100644 --- a/scripts/verify-build.mjs +++ b/scripts/verify-build.mjs @@ -1,53 +1,53 @@ -import { createRequire } from "node:module"; -import { readFileSync } from "node:fs"; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); -const { version } = require("../package.json"); +const { version } = require('../package.json'); -const HEADER = "x-hub-version"; -const TOKEN = "__HUB_VERSION__"; +const HEADER = 'x-hub-version'; +const TOKEN = '__HUB_VERSION__'; -const quoted = (literal) => new RegExp(`["']${literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']`); +const quoted = (literal) => new RegExp(`["']${literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']`); const BANNER = /^["']use client["'];/; -const bundles = ["dist/index.esm.js", "dist/index.js", "dist/webcomponent.js"]; -const withBanner = new Set(["dist/index.esm.js", "dist/index.js"]); +const bundles = ['dist/index.esm.js', 'dist/index.js', 'dist/webcomponent.js']; +const withBanner = new Set(['dist/index.esm.js', 'dist/index.js']); const failures = []; for (const file of bundles) { - let source; - - try { - source = readFileSync(new URL(`../${file}`, import.meta.url), "utf8"); - } catch { - failures.push(`${file}: missing — run \`npm run build\` first`); - continue; - } - - if (source.includes(TOKEN)) { - failures.push(`${file}: contains an unreplaced ${TOKEN}`); - } - - if (!quoted(HEADER).test(source)) { - failures.push(`${file}: does not send the ${HEADER} header`); - } - - if (!quoted(version).test(source)) { - failures.push(`${file}: not stamped with version ${version}`); - } - - if (withBanner.has(file) && !BANNER.test(source)) { - failures.push(`${file}: lost its "use client" banner`); - } + let source; + + try { + source = readFileSync(new URL(`../${file}`, import.meta.url), 'utf8'); + } catch { + failures.push(`${file}: missing — run \`npm run build\` first`); + continue; + } + + if (source.includes(TOKEN)) { + failures.push(`${file}: contains an unreplaced ${TOKEN}`); + } + + if (!quoted(HEADER).test(source)) { + failures.push(`${file}: does not send the ${HEADER} header`); + } + + if (!quoted(version).test(source)) { + failures.push(`${file}: not stamped with version ${version}`); + } + + if (withBanner.has(file) && !BANNER.test(source)) { + failures.push(`${file}: lost its "use client" banner`); + } } if (failures.length > 0) { - console.error("Build verification failed:"); - for (const failure of failures) { - console.error(` - ${failure}`); - } - process.exit(1); + console.error('Build verification failed:'); + for (const failure of failures) { + console.error(` - ${failure}`); + } + process.exit(1); } console.log(`Build verified: all bundles stamped with ${version}.`); diff --git a/src/modules/integration-picker/components/IntegrationFields.tsx b/src/modules/integration-picker/components/IntegrationFields.tsx index bc562d2..f7561ac 100644 --- a/src/modules/integration-picker/components/IntegrationFields.tsx +++ b/src/modules/integration-picker/components/IntegrationFields.tsx @@ -289,12 +289,11 @@ export const IntegrationForm: React.FC = ({ ); const { noticesBefore, noticesAfter } = partitionNotices(notices, fieldKeys); - // One recorder for the life of this form session (re-created only when the - // connector changes), NOT per schema build. `fields` gets a new identity on every - // keystroke (watch → onChange(formData) → useIntegrationPicker's fields memo), so - // the schema below rebuilds per keystroke — a recorder owned by that memo would - // reset its per-field dedupe each rebuild and dispatch one event per keystroke. - // Mirrors the unified-cloud DynamicForm and embedded-widget wiring. + // One recorder for the life of this form session (re-created only when the connector + // changes), NOT per schema build. The schema rebuilds when the connector or account + // data changes (useIntegrationPicker's `fields` memo), so a recorder owned by that memo + // would reset its per-field dedupe on those rebuilds; owning it here keeps the dedupe for + // the whole session. Mirrors the unified-cloud DynamicForm and embedded-widget wiring. const recordFailure = useMemo( () => createValidationFailureRecorder(connectorKey), [connectorKey], diff --git a/src/modules/integration-picker/types.ts b/src/modules/integration-picker/types.ts index 1a8dd30..9a42a3a 100644 --- a/src/modules/integration-picker/types.ts +++ b/src/modules/integration-picker/types.ts @@ -63,10 +63,11 @@ export interface ConnectorConfigField { value?: string | number; condition?: string; // Weaker than core's AuthenticationField, which puts `validation?: never` on the - // `select` branch (values already constrained by `options[]`) — this flat copy - // cannot express that, so a select field with `validation` would build a rule - // here. Exposure is nil in practice: connect-sdk rejects the combination at - // build time, before a config can reach the hub. + // `select` branch (values already constrained by `options[]`) — this flat copy cannot + // express that, so a select field with `validation` would build a rule here. Exposure + // is nil in practice (0 of 1695 setup/config fields across 503 connectors declare + // `validation` on a select). The build-time rejection of the combination ships in + // connect#1304 (unmerged), not connect `main` yet. validation?: FieldValidation; display?: boolean; } diff --git a/src/modules/integration-picker/utils/regexSafety.ts b/src/modules/integration-picker/utils/regexSafety.ts index 23cb756..9da6ca8 100644 --- a/src/modules/integration-picker/utils/regexSafety.ts +++ b/src/modules/integration-picker/utils/regexSafety.ts @@ -11,6 +11,12 @@ * author patterns on every keystroke. `stackone validate` rejects them at connector * build time with the canonical lint; this copy re-guards stored patterns from * connectors built before that gate existed, and the legacy TS path that never had it. + * + * Catches EXPONENTIAL backtracking only. The quadratic "adjacent unbounded quantifier" + * shape (`^a+a+$`) is a deliberate false negative here, as in the canonical lint — some + * live legacy patterns have it. The caller bounds the input length instead + * (MAX_PATTERN_INPUT_LENGTH in zodSchema.ts), the canonical docstring's own recommendation + * for that shape. */ export const hasCatastrophicBacktrackingRisk = (source: string): boolean => { const groups: { hasVariable: boolean; hasAlternation: boolean }[] = []; diff --git a/src/modules/integration-picker/utils/zodSchema.test.ts b/src/modules/integration-picker/utils/zodSchema.test.ts index 0812fd9..ca5a165 100644 --- a/src/modules/integration-picker/utils/zodSchema.test.ts +++ b/src/modules/integration-picker/utils/zodSchema.test.ts @@ -193,6 +193,47 @@ describe('createFormSchema — robustness', () => { expect(schema.safeParse({ field: 'anything' }).success).toBe(true); }); + it('warns (never fails dark) when a pattern is skipped for ReDoS or compile failure', () => { + // The client is the only enforcement layer, so a silently-dropped rule would render + // a field unvalidated with nobody noticing. Both fail-open paths must warn. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + createFormSchema([field({ validation: { pattern: '^(a+)+$' } })]); // ReDoS-risky + createFormSchema([field({ validation: { pattern: '[' } })]); // uncompilable + + expect(warn).toHaveBeenCalledTimes(2); + warn.mockRestore(); + }); + + it('does not run a ReDoS-risky author pattern on every keystroke — degrades to no rule', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const schema = createFormSchema([field({ validation: { pattern: '^(a+)+$' } })]); + + expect(schema.safeParse({ field: 'aaaa' }).success).toBe(true); + warn.mockRestore(); + }); + + it('skips an author pattern for a value over the length cap (fail-open), but never caps a format rule', () => { + // Author patterns can backtrack quadratically (the star-height lint misses that + // shape), so a value past the cap is not run. FORMAT_PATTERNS are linear/safe and + // never capped — a long invalid value still fails. + const authorSchema = createFormSchema([field({ validation: { pattern: '^[a-z]+$' } })]); + expect(authorSchema.safeParse({ field: 'A'.repeat(513) }).success).toBe(true); + + const formatSchema = createFormSchema([field({ validation: { format: 'email' } })]); + expect(formatSchema.safeParse({ field: 'x'.repeat(513) }).success).toBe(false); + }); + + it('accepts a saved-secret placeholder on a NUMBER field, so reconnect is not blocked', () => { + // The number branch returns before the string secret-placeholder short-circuit, so + // without its own guard a saved secret would fail `/^\d+$/` and gate Connect. + const schema = createFormSchema([field({ type: 'number', required: true, secret: true })]); + + expect(schema.safeParse({ field: '__secretvalue:**redacted**abcd' }).success).toBe(true); + expect(schema.safeParse({ field: '42' }).success).toBe(true); + expect(schema.safeParse({ field: 'abc' }).success).toBe(false); + }); + it('accepts the widened datetime offsets synced from @stackone/core', () => { const schema = createFormSchema([field({ validation: { format: 'datetime' } })]); @@ -215,10 +256,9 @@ describe('createValidationFailureRecorder — lifetime', () => { const dispatchEvent = vi.fn(); vi.stubGlobal('window', { dispatchEvent }); - // The form rebuilds its schema on every keystroke (fields gets a new identity - // via watch → onChange(formData) → the fields memo), so the recorder is owned - // by the component and passed in. Two builds with failing parses simulate two - // keystrokes; the dedupe must survive the rebuild. + // The schema rebuilds when the connector or account data changes (the fields memo), + // so the recorder is owned by the component and passed in. Two builds with failing + // parses simulate two rebuilds; the dedupe must survive them. const recordFailure = createValidationFailureRecorder('workday'); const fields = [field({ validation: { pattern: '^[a-z]+$' } })]; createFormSchema(fields, recordFailure).safeParse({ field: 'BAD1' }); diff --git a/src/modules/integration-picker/utils/zodSchema.ts b/src/modules/integration-picker/utils/zodSchema.ts index 83892e0..6ed0794 100644 --- a/src/modules/integration-picker/utils/zodSchema.ts +++ b/src/modules/integration-picker/utils/zodSchema.ts @@ -38,8 +38,21 @@ export const FORMAT_PATTERNS: Record = { interface ValidationRule { pattern: RegExp; errorMessage: string; + // Author-supplied patterns (legacy html-pattern/domain, Falcon pattern) pass the + // ReDoS lint but can still backtrack quadratically on long input (adjacent unbounded + // quantifiers, a shape the star-height lint deliberately misses), so their input is + // length-capped. FORMAT_PATTERNS are canonical + linear and never capped. + capInput?: boolean; } +// The star-height lint (regexSafety.ts, canonical connect-sdk copy) catches exponential +// backtracking; it does not catch the quadratic "adjacent unbounded quantifier" shape, +// which some live legacy patterns have. Bound the value fed to an author pattern so a +// pathological one can't hang the tab on crafted long input — the same mitigation the +// canonical lint's own docstring recommends. Auth values (tenant, key, url) are far +// shorter; a value over the cap fails open (skips the rule), matching the degrade elsewhere. +const MAX_PATTERN_INPUT_LENGTH = 512; + function isLegacyValidation(validation: FieldValidation): validation is LegacyFieldValidation { return validation.type !== undefined; } @@ -51,14 +64,22 @@ function isLegacyValidation(validation: FieldValidation): validation is LegacyFi // every keystroke, and only the compile guard could ever catch the first hazard, not the // second. connect-sdk rejects both at connector build time, but that gate covers neither // connectors built before it existed nor the legacy TS path, so re-guard both here. -// Fail open: a skipped rule degrades to today's no-validation behaviour. +// Fail open but loudly: a skipped rule degrades to today's no-validation behaviour, and +// each path warns so the field never renders unvalidated silently on the only enforcement +// layer (matches the unknown-format branch below). function compileRegex(source: string): RegExp | null { if (hasCatastrophicBacktrackingRisk(source)) { + console.warn( + `[stackone-hub] pattern "${source}" risks catastrophic backtracking — field validation skipped`, + ); return null; } try { return new RegExp(source); } catch { + console.warn( + `[stackone-hub] pattern "${source}" failed to compile — field validation skipped`, + ); return null; } } @@ -70,6 +91,7 @@ function resolveLegacyRule(validation: LegacyFieldValidation): ValidationRule | if (!pattern) return null; return { pattern, + capInput: true, errorMessage: validation.error || `Please match the required format: ${validation.pattern}`, }; @@ -80,6 +102,7 @@ function resolveLegacyRule(validation: LegacyFieldValidation): ValidationRule | if (!pattern) return null; return { pattern, + capInput: true, errorMessage: validation.error || `Please enter a valid ${validation.pattern}.com domain`, }; @@ -117,6 +140,7 @@ function resolveFalconRule( if (!pattern) return null; return { pattern, + capInput: true, errorMessage: validation.errorMessage || `${label} format is invalid`, }; } @@ -143,14 +167,13 @@ export type RecordValidationFailure = ( // credentials). Hosts or StackOne scripts can listen via // window.addEventListener('stackone-hub:field-validation-failed', ...). // -// Lifetime: the recorder must be owned by the rendering component for the life of -// the form session (useMemo keyed on the connector) and passed into -// createFormSchema — NOT created per schema build. The schema does not survive a -// keystroke (keystroke → onChange(formData) → new `fields` identity in -// useIntegrationPicker → schema useMemo rebuilds), so a recorder owned by the -// schema would reset its per-field dedupe on every rebuild and dispatch one event -// per keystroke instead of at most once per field. No-op outside the browser -// (e.g. the npm-test vector check). +// Lifetime: the recorder must be owned by the rendering component for the life of the +// form session (useMemo keyed on the connector) and passed into createFormSchema — NOT +// created per schema build. The schema is rebuilt whenever the connector or account data +// changes (useIntegrationPicker's `fields` memo, deps [connectorData, selectedIntegration, +// accountData, hubData]), so a recorder owned by the schema would reset its per-field +// dedupe on each of those rebuilds; owning it in the component keeps the dedupe for the +// whole session. No-op outside the browser (e.g. the npm-test vector check). export function createValidationFailureRecorder(connector?: string): RecordValidationFailure { const firedFields = new Set(); @@ -187,13 +210,20 @@ function createFieldSchema( } if (field.type === 'number') { + // Per the connector schema, number fields carry no `validation:`, so they never + // reach the rule section below. A saved secret pre-fills as the redacted sentinel + // and must pass on reconnect (same as string fields), so admit it alongside the + // numeric check — otherwise `/^\d+$/` rejects the sentinel and gates Connect. + const isNumericOrSecret = (val: string) => isSecretPlaceholder(val) || /^\d+$/.test(val); if (field.required) { - schema = schema.regex(/^\d+$/, 'Must be a valid number'); - } else { return z .string() - .refine((val) => val === '' || /^\d+$/.test(val), 'Must be a valid number'); + .min(1, `${field.label} is required`) + .refine(isNumericOrSecret, 'Must be a valid number'); } + return z + .string() + .refine((val) => val === '' || isNumericOrSecret(val), 'Must be a valid number'); } const validation = field.validation; @@ -206,6 +236,9 @@ function createFieldSchema( // 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; + // An author pattern over the length cap can't be run safely (see + // MAX_PATTERN_INPUT_LENGTH); skip it (fail-open) rather than risk a hang. + if (rule.capInput && val.length > MAX_PATTERN_INPUT_LENGTH) 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 diff --git a/tsconfig.json b/tsconfig.json index 863a081..5f98412 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,7 @@ "jsxImportSource": "react", "forceConsistentCasingInFileNames": true }, - "include": ["src"], + "include": ["src", "scripts"], "exclude": ["**/*.d.ts"], "files": ["src/index.ts"] }