1// Alchemy modifications are licensed under Apache-2.0.
2// This file includes third-party code; see /THIRD_PARTY_LICENSES.md.
3// Alchemy modifications: uses Effect Schema instead of Zod, preserves strict excess-property validation, and supports function retry delays.
4import * as Result from "effect/Result";
5import * as Schema from "effect/Schema";
6import { ms } from "itty-time";
7
8export const SENSITIVE_STEP_OUTPUT = "output";
9
10export const MAX_WORKFLOW_NAME_LENGTH = 64;
11
12export const MAX_WORKFLOW_INSTANCE_ID_LENGTH = 100;
13
14export const MAX_ADDRESSABLE_WORKFLOW_INSTANCE_ID_LENGTH = 271;
15
16export const MAX_STEP_NAME_LENGTH = 256;
17
18export const ALLOWED_STRING_ID_PATTERN = "^[a-zA-Z0-9_][a-zA-Z0-9-_]*$";
19export const ALLOWED_ADDRESSABLE_WORKFLOW_INSTANCE_ID_PATTERN =
20 "^[a-zA-Z0-9, */#_-]+$";
21const ALLOWED_WORKFLOW_INSTANCE_ID_REGEX = new RegExp(ALLOWED_STRING_ID_PATTERN);
22const ALLOWED_ADDRESSABLE_WORKFLOW_INSTANCE_ID_REGEX = new RegExp(
23 ALLOWED_ADDRESSABLE_WORKFLOW_INSTANCE_ID_PATTERN,
24);
25const ALLOWED_WORKFLOW_NAME_REGEX = ALLOWED_WORKFLOW_INSTANCE_ID_REGEX;
26
27// eslint-disable-next-line no-control-regex -- intentional use of control character range to detect invalid characters in workflow names
28const CONTROL_CHAR_REGEX = new RegExp("[\x00-\x1F]");
29
30export function isValidWorkflowName(name: string): boolean {
31 if (typeof name !== "string") {
32 return false;
33 }
34 if (name.length > MAX_WORKFLOW_NAME_LENGTH) {
35 return false;
36 }
37
38 return ALLOWED_WORKFLOW_NAME_REGEX.test(name);
39}
40
41export function isValidWorkflowInstanceId(id: string): boolean {
42 if (typeof id !== "string") {
43 return false;
44 }
45
46 if (id.length > MAX_WORKFLOW_INSTANCE_ID_LENGTH) {
47 return false;
48 }
49
50 return ALLOWED_WORKFLOW_INSTANCE_ID_REGEX.test(id);
51}
52
53/** Validates IDs that address existing instances, including generated cron IDs. */
54export function isValidAddressableWorkflowInstanceId(id: string): boolean {
55 return (
56 typeof id === "string" &&
57 id.length > 0 &&
58 id.length <= MAX_ADDRESSABLE_WORKFLOW_INSTANCE_ID_LENGTH &&
59 ALLOWED_ADDRESSABLE_WORKFLOW_INSTANCE_ID_REGEX.test(id)
60 );
61}
62
63export function isValidStepName(name: string): boolean {
64 if (name.length > MAX_STEP_NAME_LENGTH) {
65 return false;
66 }
67
68 return !CONTROL_CHAR_REGEX.test(name);
69}
70
71const NonNegativeNumberOrString = Schema.Union([
72 Schema.Number.check(Schema.isGreaterThanOrEqualTo(0)),
73 Schema.String,
74]);
75
76const STEP_CONFIG_SCHEMA = Schema.Struct({
77 retries: Schema.optional(
78 Schema.Struct({
79 delay: Schema.Unknown,
80 limit: Schema.Number.check(Schema.isGreaterThanOrEqualTo(0)),
81 backoff: Schema.optional(Schema.Literals(["constant", "linear", "exponential"])),
82 }),
83 ),
84 timeout: Schema.optional(NonNegativeNumberOrString),
85 sensitive: Schema.optional(Schema.Literal(SENSITIVE_STEP_OUTPUT)),
86});
87
88const decodeStepConfig = Schema.decodeUnknownResult(STEP_CONFIG_SCHEMA);
89
90export function isValidStepConfig(stepConfig: unknown): boolean {
91 const config = decodeStepConfig(stepConfig, { onExcessProperty: "error" });
92
93 if (Result.isFailure(config)) {
94 return false;
95 }
96
97 const data = config.success;
98
99 if (data.retries !== undefined) {
100 const delay = data.retries.delay;
101 if (
102 !(
103 typeof delay === "function" ||
104 typeof delay === "string" ||
105 (typeof delay === "number" && delay >= 0)
106 ) ||
107 (typeof delay !== "function" && Number.isNaN(ms(delay)))
108 ) {
109 return false;
110 }
111 }
112
113 if (data.timeout !== undefined) {
114 const timeout = data.timeout;
115 if (timeout == 0 || Number.isNaN(ms(timeout))) {
116 return false;
117 }
118 }
119
120 return true;
121}