PSA/shared/workflow/runtime/validation/mappingValidator.ts
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

397 lines
12 KiB
TypeScript

/**
* Mapping Validator
*
* Validates InputMapping values for publish validation.
* Checks expression syntax, secret reference validity, and type compatibility.
*/
import type { PublishError, MappingValue, InputMapping } from '../types';
import { isExpr, isSecretRef } from '../types';
import { validateExpressionSource } from '../expressionEngine';
import { secretNameSchema } from '../../secrets';
/**
* Options for mapping validation.
*/
export interface MappingValidationOptions {
/**
* Step path for error reporting (e.g., "root.steps[0]")
*/
stepPath: string;
/**
* Step ID for error reporting
*/
stepId: string;
/**
* Field name being validated (e.g., "inputMapping")
*/
fieldName: string;
/**
* Optional set of known secret names to validate against.
* If provided, will warn about references to unknown secrets.
*/
knownSecrets?: Set<string>;
/**
* Optional action input schema to validate required fields.
*/
requiredFields?: string[];
}
/**
* Result of mapping validation.
*/
export interface MappingValidationResult {
errors: PublishError[];
warnings: PublishError[];
/**
* Set of secret names referenced in the mapping.
* Useful for checking if all secrets exist before publishing.
*/
secretRefs: Set<string>;
}
type JsonSchema = Record<string, unknown>;
const isObjectLike = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
/**
* Validate a single MappingValue.
*
* @param value - The value to validate
* @param keyPath - The JSON path to this value (e.g., "inputMapping.to")
* @param options - Validation options
* @param result - Result accumulator
*/
function validateMappingValue(
value: MappingValue,
keyPath: string,
options: MappingValidationOptions,
result: MappingValidationResult
): void {
// Handle expressions: { $expr: "..." }
if (isExpr(value)) {
if (!value.$expr || value.$expr.trim() === '') {
result.errors.push({
severity: 'error',
stepPath: options.stepPath,
stepId: options.stepId,
code: 'EMPTY_EXPRESSION',
message: `Empty expression at ${keyPath}`
});
return;
}
try {
validateExpressionSource(value.$expr);
} catch (error) {
result.errors.push({
severity: 'error',
stepPath: options.stepPath,
stepId: options.stepId,
code: 'INVALID_EXPRESSION',
message: `Invalid expression at ${keyPath}: ${error instanceof Error ? error.message : 'Unknown error'}`
});
}
return;
}
// Handle secret references: { $secret: "SECRET_NAME" }
if (isSecretRef(value)) {
const secretName = value.$secret;
// Validate secret name format
const nameValidation = secretNameSchema.safeParse(secretName);
if (!nameValidation.success) {
result.errors.push({
severity: 'error',
stepPath: options.stepPath,
stepId: options.stepId,
code: 'INVALID_SECRET_NAME',
message: `Invalid secret name format at ${keyPath}: ${nameValidation.error.issues[0]?.message ?? 'Invalid format'}`
});
return;
}
// Track the secret reference
result.secretRefs.add(secretName);
// Warn if secret is not in the known list (optional check)
if (options.knownSecrets && !options.knownSecrets.has(secretName)) {
result.warnings.push({
severity: 'warning',
stepPath: options.stepPath,
stepId: options.stepId,
code: 'UNKNOWN_SECRET',
message: `Secret "${secretName}" referenced at ${keyPath} may not exist`
});
}
return;
}
// Handle literal values (recursively validate nested objects/arrays)
if (Array.isArray(value)) {
value.forEach((item, index) => {
validateMappingValue(item as MappingValue, `${keyPath}[${index}]`, options, result);
});
return;
}
if (value !== null && typeof value === 'object') {
// Check for invalid special keys
const obj = value as Record<string, unknown>;
for (const key of Object.keys(obj)) {
if (key.startsWith('$') && key !== '$expr' && key !== '$secret') {
result.warnings.push({
severity: 'warning',
stepPath: options.stepPath,
stepId: options.stepId,
code: 'UNKNOWN_SPECIAL_KEY',
message: `Unknown special key "${key}" at ${keyPath}.${key} - did you mean $expr or $secret?`
});
}
validateMappingValue(obj[key] as MappingValue, `${keyPath}.${key}`, options, result);
}
return;
}
// Primitive literals (string, number, boolean, null) are always valid
}
function resolveSchemaRef(schema: JsonSchema, root: JsonSchema): JsonSchema {
if (!schema || typeof schema !== 'object') return schema;
const ref = (schema as { $ref?: string }).$ref;
if (!ref || !root || typeof root !== 'object') return schema;
if (!ref.startsWith('#/definitions/')) return schema;
const key = ref.split('/').pop() ?? '';
const definitions = (root as { definitions?: Record<string, JsonSchema> }).definitions;
const resolved = definitions?.[key];
if (!resolved) return schema;
return resolveSchemaRef(resolved, root);
}
function mergeAllOf(schema: JsonSchema, root: JsonSchema): JsonSchema {
const allOf = (schema as { allOf?: JsonSchema[] }).allOf;
if (!Array.isArray(allOf) || allOf.length === 0) return schema;
const base: JsonSchema = { ...schema };
delete (base as { allOf?: JsonSchema[] }).allOf;
const merged: JsonSchema = { ...base };
for (const part of allOf) {
const resolved = resolveSchemaRef(part, root);
const mergedResolved = mergeAllOf(resolved, root);
const props = (mergedResolved as { properties?: Record<string, JsonSchema> }).properties;
if (props) {
merged.properties = {
...(merged as { properties?: Record<string, JsonSchema> }).properties,
...props
};
}
const required = (mergedResolved as { required?: string[] }).required;
if (required) {
merged.required = Array.from(new Set([
...((merged as { required?: string[] }).required ?? []),
...required
]));
}
}
return merged;
}
function normalizeSchema(schema: JsonSchema, root: JsonSchema): JsonSchema {
const resolved = resolveSchemaRef(schema, root);
const merged = mergeAllOf(resolved, root);
return merged;
}
function isObjectSchema(schema: JsonSchema): boolean {
const type = (schema as { type?: string | string[] }).type;
if (type === 'object') return true;
if (Array.isArray(type) && type.includes('object')) return true;
return !!(schema as { properties?: Record<string, JsonSchema> }).properties;
}
function shouldRecurseIntoValue(value: unknown): value is Record<string, unknown> {
if (!isObjectLike(value)) return false;
if (isExpr(value) || isSecretRef(value)) return false;
if (Array.isArray(value)) return false;
return true;
}
function validateRequiredAgainstSchema(
mapping: InputMapping | undefined,
schema: JsonSchema | undefined,
options: MappingValidationOptions
): PublishError[] {
const errors: PublishError[] = [];
if (!schema) return errors;
const rootSchema = schema;
const rootMapping = mapping ?? {};
const validateObject = (currentSchema: JsonSchema, currentMapping: Record<string, unknown>, pathPrefix: string) => {
const normalized = normalizeSchema(currentSchema, rootSchema);
if (!isObjectSchema(normalized)) return;
const properties = (normalized as { properties?: Record<string, JsonSchema> }).properties ?? {};
const required = (normalized as { required?: string[] }).required ?? [];
for (const key of required) {
if (!Object.prototype.hasOwnProperty.call(currentMapping, key)) {
errors.push({
severity: 'error',
stepPath: options.stepPath,
stepId: options.stepId,
code: 'MISSING_REQUIRED_MAPPING',
message: `Required field "${pathPrefix}.${key}" is not mapped in ${options.fieldName}`
});
}
}
for (const [key, propSchema] of Object.entries(properties)) {
if (!Object.prototype.hasOwnProperty.call(currentMapping, key)) continue;
const value = currentMapping[key];
if (!shouldRecurseIntoValue(value)) continue;
validateObject(propSchema, value, `${pathPrefix}.${key}`);
}
};
validateObject(rootSchema, rootMapping as Record<string, unknown>, options.fieldName);
return errors;
}
/**
* Validate an InputMapping.
*
* @param mapping - The mapping to validate
* @param options - Validation options
* @returns Validation result with errors, warnings, and secret references
*/
export function validateInputMapping(
mapping: InputMapping | undefined,
options: MappingValidationOptions
): MappingValidationResult {
const result: MappingValidationResult = {
errors: [],
warnings: [],
secretRefs: new Set()
};
if (!mapping) {
// Check if there are required fields that aren't mapped
if (options.requiredFields && options.requiredFields.length > 0) {
for (const field of options.requiredFields) {
result.errors.push({
severity: 'error',
stepPath: options.stepPath,
stepId: options.stepId,
code: 'MISSING_REQUIRED_MAPPING',
message: `Required field "${field}" is not mapped in ${options.fieldName}`
});
}
}
return result;
}
// Check for required fields
if (options.requiredFields) {
const mappedFields = new Set(Object.keys(mapping));
for (const field of options.requiredFields) {
if (!mappedFields.has(field)) {
result.errors.push({
severity: 'error',
stepPath: options.stepPath,
stepId: options.stepId,
code: 'MISSING_REQUIRED_MAPPING',
message: `Required field "${field}" is not mapped in ${options.fieldName}`
});
}
}
}
// Validate each mapping entry
for (const [key, value] of Object.entries(mapping)) {
const keyPath = `${options.fieldName}.${key}`;
validateMappingValue(value, keyPath, options, result);
}
return result;
}
/**
* Validate required mappings against a JSON schema (deep required fields).
* Only enforces nested required fields when the parent is mapped as an object literal.
*/
export function validateInputMappingSchema(
mapping: InputMapping | undefined,
schema: JsonSchema | undefined,
options: MappingValidationOptions
): PublishError[] {
return validateRequiredAgainstSchema(mapping, schema, options);
}
/**
* Collect all secret references from a mapping (non-validating).
* Useful for quick extraction without full validation.
*
* @param mapping - The mapping to scan
* @returns Set of secret names referenced
*/
export function collectSecretRefs(mapping: InputMapping | undefined): Set<string> {
const refs = new Set<string>();
if (!mapping) return refs;
const scan = (value: unknown): void => {
if (!value || typeof value !== 'object') return;
if (isSecretRef(value as MappingValue)) {
refs.add((value as { $secret: string }).$secret);
return;
}
if (Array.isArray(value)) {
value.forEach(scan);
return;
}
Object.values(value as Record<string, unknown>).forEach(scan);
};
Object.values(mapping).forEach(scan);
return refs;
}
/**
* Collect all secret references from an arbitrary config object.
* Scans deeply for { $secret: "..." } patterns.
*
* @param config - The config object to scan
* @returns Set of secret names referenced
*/
export function collectSecretRefsFromConfig(config: unknown): Set<string> {
const refs = new Set<string>();
const scan = (value: unknown): void => {
if (!value || typeof value !== 'object') return;
if (isSecretRef(value as MappingValue)) {
refs.add((value as { $secret: string }).$secret);
return;
}
if (Array.isArray(value)) {
value.forEach(scan);
return;
}
Object.values(value as Record<string, unknown>).forEach(scan);
};
scan(config);
return refs;
}