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
491 lines
16 KiB
TypeScript
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;
|
|
}
|