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
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
397 lines
12 KiB
TypeScript
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;
|
|
}
|