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..029f52a 100644 --- a/src/modules/integration-picker/utils/zodSchema.ts +++ b/src/modules/integration-picker/utils/zodSchema.ts @@ -1,5 +1,85 @@ import { z } from 'zod'; -import { ConnectorConfigField } from '../types'; +import { + ConnectorConfigField, + FalconFieldValidation, + FieldValidation, + FormatName, + LegacyFieldValidation, +} from '../types'; + +// 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); +} function createFieldSchema(field: ConnectorConfigField): z.ZodTypeAny { let schema: z.ZodString = z.string(); @@ -18,29 +98,14 @@ 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 rule = resolveValidationRule(field); + if (rule) { + if (field.required) { + schema = schema.regex(rule.pattern, rule.errorMessage); + } else { + return z + .string() + .refine((val) => val === '' || rule.pattern.test(val), rule.errorMessage); } }