PSA/shared/workflow/runtime/validation/publishValidation.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

491 lines
16 KiB
TypeScript

import { z } from 'zod';
import type { WorkflowDefinition, PublishError, Step, NodeStep, InputMapping } from '../types';
import { workflowDefinitionSchema } from '../types';
import { getNodeTypeRegistry } from '../registries/nodeTypeRegistry';
import { getActionRegistryV2 } from '../registries/actionRegistry';
import { validateExpressionSource } from '../expressionEngine';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { validateInputMapping, validateInputMappingSchema, collectSecretRefsFromConfig } from './mappingValidator';
import {
isWorkflowAiInferAction,
resolveWorkflowAiSchemaFromConfig,
} from '../ai/aiSchema';
export type PublishValidationResult = {
ok: boolean;
errors: PublishError[];
warnings: PublishError[];
/**
* Set of secret names referenced in the workflow.
* Can be used to verify all secrets exist before publishing.
*/
secretRefs: Set<string>;
};
export function validateWorkflowDefinition(
definition: WorkflowDefinition,
payloadSchemaJson?: Record<string, unknown>,
knownSecrets?: Set<string>
): PublishValidationResult {
const errors: PublishError[] = [];
const warnings: PublishError[] = [];
const secretRefs = new Set<string>();
try {
workflowDefinitionSchema.parse(definition);
} catch (error) {
if (error instanceof z.ZodError) {
for (const issue of error.issues) {
errors.push({
severity: 'error',
stepPath: 'root',
code: 'INVALID_WORKFLOW_DEFINITION',
message: issue.message
});
}
} else {
errors.push({
severity: 'error',
stepPath: 'root',
code: 'INVALID_WORKFLOW_DEFINITION',
message: 'Workflow definition failed schema validation'
});
}
}
try {
JSON.stringify(definition);
} catch (error) {
errors.push({
severity: 'error',
stepPath: 'root',
code: 'NON_SERIALIZABLE',
message: 'Workflow definition is not JSON-serializable'
});
}
const stepIds = new Set<string>();
const nodeRegistry = getNodeTypeRegistry();
const actionRegistry = getActionRegistryV2();
if (!Array.isArray(definition.steps)) {
return {
ok: errors.length === 0,
errors,
warnings,
secretRefs
};
}
const visitSteps = (steps: Step[], prefix: string) => {
steps.forEach((step, index) => {
const stepPath = `${prefix}.steps[${index}]`;
if (stepIds.has(step.id)) {
errors.push({
severity: 'error',
stepPath,
stepId: step.id,
code: 'DUPLICATE_STEP_ID',
message: `Duplicate step id ${step.id}`
});
} else {
stepIds.add(step.id);
}
if (step.type === 'control.if') {
const ifStep = step as Step & { type: 'control.if'; condition: { $expr: string }; then: Step[]; else?: Step[] };
validateExpr(ifStep.condition, stepPath, step.id, errors);
visitSteps(ifStep.then, `${stepPath}.then`);
if (ifStep.else) {
visitSteps(ifStep.else, `${stepPath}.else`);
}
return;
}
if (step.type === 'control.forEach') {
const forEachStep = step as Step & { type: 'control.forEach'; items: { $expr: string }; body: Step[] };
validateExpr(forEachStep.items, stepPath, step.id, errors);
visitSteps(forEachStep.body, `${stepPath}.body`);
return;
}
if (step.type === 'control.tryCatch') {
const tryCatchStep = step as Step & { type: 'control.tryCatch'; try: Step[]; catch: Step[] };
visitSteps(tryCatchStep.try, `${stepPath}.try`);
visitSteps(tryCatchStep.catch, `${stepPath}.catch`);
return;
}
if (step.type === 'control.callWorkflow') {
const callStep = step as Step & { type: 'control.callWorkflow'; inputMapping?: Record<string, { $expr: string }>; outputMapping?: Record<string, { $expr: string }> };
if (callStep.inputMapping) {
Object.values(callStep.inputMapping).forEach((expr) => validateExpr(expr, stepPath, step.id, errors));
}
if (callStep.outputMapping) {
Object.values(callStep.outputMapping).forEach((expr) => validateExpr(expr, stepPath, step.id, errors));
}
return;
}
if (step.type === 'control.return') {
return;
}
validateNodeStep(step, stepPath, errors, warnings, secretRefs, knownSecrets, payloadSchemaJson, nodeRegistry, actionRegistry);
});
};
visitSteps(definition.steps, 'root');
return {
ok: errors.length === 0,
errors,
warnings,
secretRefs
};
}
function validateExpr(expr: { $expr: string }, stepPath: string, stepId: string, errors: PublishError[]) {
try {
validateExpressionSource(expr.$expr);
} catch (error) {
errors.push({
severity: 'error',
stepPath,
stepId,
code: 'INVALID_EXPR',
message: error instanceof Error ? error.message : 'Invalid expression'
});
}
}
function validateNodeStep(
step: NodeStep,
stepPath: string,
errors: PublishError[],
warnings: PublishError[],
secretRefs: Set<string>,
knownSecrets: Set<string> | undefined,
payloadSchemaJson: Record<string, unknown> | undefined,
nodeRegistry: ReturnType<typeof getNodeTypeRegistry>,
actionRegistry: ReturnType<typeof getActionRegistryV2>
) {
const nodeType = nodeRegistry.get(step.type);
if (!nodeType) {
errors.push({
severity: 'error',
stepPath,
stepId: step.id,
code: 'UNKNOWN_NODE_TYPE',
message: `Unknown node type: ${step.type}`
});
return;
}
if (step.config) {
const result = nodeType.configSchema.safeParse(step.config);
if (!result.success) {
errors.push({
severity: 'error',
stepPath,
stepId: step.id,
code: 'INVALID_CONFIG',
message: result.error.issues.map((issue) => issue.message).join('; ')
});
}
collectExprs(step.config).forEach((expr) => validateExpr(expr, stepPath, step.id, errors));
if (step.type === 'action.call') {
const config = step.config as { actionId?: string; version?: number; inputMapping?: InputMapping };
if (!config || !config.actionId || !config.version) {
errors.push({
severity: 'error',
stepPath,
stepId: step.id,
code: 'INVALID_ACTION_CONFIG',
message: 'action.call requires actionId and version'
});
} else {
const action = actionRegistry.get(config.actionId, config.version);
if (!action) {
errors.push({
severity: 'error',
stepPath,
stepId: step.id,
code: 'UNKNOWN_ACTION',
message: `Unknown action ${config.actionId}@${config.version}`
});
} else {
// Validate inputMapping if present
if (config.inputMapping) {
const mappingResult = validateInputMapping(config.inputMapping, {
stepPath,
stepId: step.id,
fieldName: 'inputMapping',
knownSecrets
});
errors.push(...mappingResult.errors);
warnings.push(...mappingResult.warnings);
mappingResult.secretRefs.forEach((ref) => secretRefs.add(ref));
}
if (isWorkflowAiInferAction(config.actionId)) {
const resolvedAiSchema = resolveWorkflowAiSchemaFromConfig(config);
if (!resolvedAiSchema.mode) {
errors.push({
severity: 'error',
stepPath,
stepId: step.id,
code: 'INVALID_AI_OUTPUT_SCHEMA',
message: 'AI inference steps require aiOutputSchemaMode and an inline aiOutputSchema.',
});
} else if (resolvedAiSchema.errors.length > 0 || !resolvedAiSchema.schema) {
errors.push({
severity: 'error',
stepPath,
stepId: step.id,
code: 'INVALID_AI_OUTPUT_SCHEMA',
message: resolvedAiSchema.errors[0] ?? 'AI inference output schema is invalid.',
});
}
}
const actionSchemaJson = zodToJsonSchema(action.inputSchema, { name: `${action.id}@${action.version}.input` }) as Record<string, unknown>;
const requiredErrors = validateInputMappingSchema(config.inputMapping, actionSchemaJson, {
stepPath,
stepId: step.id,
fieldName: 'inputMapping',
knownSecrets
});
errors.push(...requiredErrors);
}
}
// Collect secret refs from the entire config (in case there are secrets in other fields)
const configSecrets = collectSecretRefsFromConfig(config);
configSecrets.forEach((ref) => secretRefs.add(ref));
}
if (step.type === 'transform.assign') {
const config = step.config as { assign?: Record<string, { $expr: string }> };
if (config?.assign) {
for (const path of Object.keys(config.assign)) {
validateAssignmentPath(path, stepPath, step.id, errors);
}
}
if (config?.assign && payloadSchemaJson) {
for (const path of Object.keys(config.assign)) {
if (!isAllowedAssignPath(path, payloadSchemaJson)) {
warnings.push({
severity: 'warning',
stepPath,
stepId: step.id,
code: 'ASSIGN_PATH_UNKNOWN',
message: `Assign path may be invalid: ${path}`
});
}
}
}
}
if (step.type === 'event.wait') {
const config = step.config as { assign?: Record<string, { $expr: string }> };
if (config?.assign) {
for (const path of Object.keys(config.assign)) {
validateAssignmentPath(path, stepPath, step.id, errors);
}
}
}
if (step.type === 'time.wait') {
const config = step.config as { assign?: Record<string, { $expr: string }> };
if (config?.assign) {
for (const path of Object.keys(config.assign)) {
validateAssignmentPath(path, stepPath, step.id, errors);
}
}
if (config?.assign && payloadSchemaJson) {
for (const path of Object.keys(config.assign)) {
if (!isAllowedAssignPath(path, payloadSchemaJson)) {
warnings.push({
severity: 'warning',
stepPath,
stepId: step.id,
code: 'ASSIGN_PATH_UNKNOWN',
message: `Assign path may be invalid: ${path}`
});
}
}
}
}
}
}
function collectExprs(value: unknown): Array<{ $expr: string }> {
const exprs: Array<{ $expr: string }> = [];
if (!value) return exprs;
if (Array.isArray(value)) {
value.forEach((item) => exprs.push(...collectExprs(item)));
return exprs;
}
if (typeof value === 'object') {
if ('$expr' in (value as Record<string, unknown>)) {
exprs.push(value as { $expr: string });
return exprs;
}
Object.values(value as Record<string, unknown>).forEach((val) => exprs.push(...collectExprs(val)));
}
return exprs;
}
function isAllowedAssignPath(path: string, payloadSchemaJson: Record<string, unknown>): boolean {
if (!path) return false;
if (path.startsWith('vars.') || path.startsWith('meta.') || path.startsWith('error.')) {
return true;
}
if (path.startsWith('payload.')) {
return pathExistsInSchema(path.replace(/^payload\./, ''), payloadSchemaJson);
}
if (path.startsWith('/')) {
return pathExistsInSchema(path.replace(/^\//, ''), payloadSchemaJson);
}
return true;
}
function validateAssignmentPath(path: string, stepPath: string, stepId: string, errors: PublishError[]): void {
if (!path || typeof path !== 'string') {
errors.push({
severity: 'error',
stepPath,
stepId,
code: 'INVALID_ASSIGN_PATH',
message: 'Assignment path must be a non-empty string'
});
return;
}
const allowed = path.startsWith('payload.')
|| path.startsWith('vars.')
|| path.startsWith('meta.')
|| path.startsWith('error.')
|| path.startsWith('/');
if (!allowed) {
errors.push({
severity: 'error',
stepPath,
stepId,
code: 'INVALID_ASSIGN_PATH',
message: `Assignment path must be scoped (payload/vars/meta/error or JSON pointer): ${path}`
});
return;
}
const segments = path.replace(/^\/?/, '').split(/[./]/).filter(Boolean);
const forbidden = new Set(['__proto__', 'prototype', 'constructor']);
for (const segment of segments) {
if (forbidden.has(segment)) {
errors.push({
severity: 'error',
stepPath,
stepId,
code: 'INVALID_ASSIGN_PATH',
message: `Assignment path contains forbidden segment: ${segment}`
});
return;
}
}
}
function pathExistsInSchema(path: string, payloadSchemaJson: Record<string, unknown>): boolean {
if (!path) return true;
const parts = path.split('.').filter(Boolean);
let cursor: any = payloadSchemaJson;
for (const part of parts) {
if (!cursor || typeof cursor !== 'object') return false;
const properties = (cursor as any).properties;
if (!properties || typeof properties !== 'object' || !(part in properties)) {
return false;
}
cursor = properties[part];
}
return true;
}
/**
* Verify that all referenced secrets exist.
* Call this after validateWorkflowDefinition with the secretRefs.
*
* @param secretRefs - Set of secret names referenced in the workflow
* @param existingSecrets - Set of secret names that actually exist
* @returns Validation errors for missing secrets
*/
export function verifySecretsExist(
secretRefs: Set<string>,
existingSecrets: Set<string>
): PublishError[] {
const errors: PublishError[] = [];
for (const secretName of secretRefs) {
if (!existingSecrets.has(secretName)) {
errors.push({
severity: 'error',
stepPath: 'workflow',
code: 'SECRET_NOT_FOUND',
message: `Secret "${secretName}" is referenced but does not exist. Create it in Settings → Secrets.`
});
}
}
return errors;
}
/**
* Check if any referenced secrets haven't been accessed recently.
* Returns warnings for potentially stale secrets.
*
* @param secretRefs - Set of secret names referenced in the workflow
* @param secretLastAccessed - Map of secret name to last accessed timestamp (or undefined if never accessed)
* @param staleThresholdDays - Number of days after which a secret is considered stale (default: 90)
* @returns Validation warnings for stale secrets
*/
export function checkStaleSecrets(
secretRefs: Set<string>,
secretLastAccessed: Map<string, string | undefined>,
staleThresholdDays = 90
): PublishError[] {
const warnings: PublishError[] = [];
const now = Date.now();
const staleThreshold = staleThresholdDays * 24 * 60 * 60 * 1000;
for (const secretName of secretRefs) {
const lastAccessed = secretLastAccessed.get(secretName);
if (!lastAccessed) {
warnings.push({
severity: 'warning',
stepPath: 'workflow',
code: 'SECRET_NEVER_ACCESSED',
message: `Secret "${secretName}" has never been accessed. Verify it contains the correct value.`
});
continue;
}
const lastAccessedTime = new Date(lastAccessed).getTime();
if (now - lastAccessedTime > staleThreshold) {
const daysSinceAccess = Math.floor((now - lastAccessedTime) / (24 * 60 * 60 * 1000));
warnings.push({
severity: 'warning',
stepPath: 'workflow',
code: 'SECRET_STALE',
message: `Secret "${secretName}" hasn't been accessed in ${daysSinceAccess} days. Verify it's still valid.`
});
}
}
return warnings;
}