Mirrored from GitHub
github.com/roostorg/coop
13 kB
407 lines
1import _Ajv, { type ErrorObject } from 'ajv-draft-04';
2import { type JsonValue } from 'type-fest';
3
4import { makeBadRequestError } from '../../../utils/errors.js';
5import { assertUnreachable } from '../../../utils/misc.js';
6
7// `ajv-draft-04` is CJS.
8const Ajv = _Ajv as unknown as typeof _Ajv.default;
9
10export const ACTION_PARAMETER_TYPES = [
11 'STRING',
12 'NUMBER',
13 'BOOLEAN',
14 'SELECT',
15 'MULTISELECT',
16] as const;
17export type ActionParameterType = (typeof ACTION_PARAMETER_TYPES)[number];
18
19export type ActionParameterOption = {
20 value: string;
21 label: string;
22};
23
24export type ActionParameter = {
25 name: string;
26 displayName: string;
27 description?: string;
28 type: ActionParameterType;
29 required: boolean;
30 options?: readonly ActionParameterOption[];
31 min?: number;
32 max?: number;
33 maxLength?: number;
34 defaultValue?: unknown;
35};
36
37/**
38 * Pre-validation shape — what GraphQL/REST callers hand us before AJV runs.
39 * Looser than `ActionParameter` (any string-keyed object); we use this in
40 * service-layer signatures so the type makes the "needs validation" boundary
41 * obvious without forcing callers to pre-narrow null vs undefined.
42 */
43export type RawActionParameterInput = Readonly<Record<string, unknown>>;
44
45// Names become keys in the webhook payload's `body.custom`. We allow letters,
46// digits, `_`, `-`, and `.` so consumers can use snake_case, kebab-case, or
47// dotted namespacing; whitespace, quotes, and brackets are rejected because
48// they break dotted access in most languages and need escaping in URLs/logs.
49const PARAMETER_NAME_PATTERN = '^[a-zA-Z0-9_.\\-]+$';
50
51const optionSchema = {
52 type: 'object',
53 additionalProperties: false,
54 required: ['value', 'label'],
55 properties: {
56 value: { type: 'string', minLength: 1, maxLength: 200 },
57 label: { type: 'string', minLength: 1, maxLength: 200 },
58 },
59} as const;
60
61// Structural shape only. Per-type rules (options required for SELECT, default
62// matches type, min<=max) live in `validatePerTypeRules` because expressing
63// them in JSON Schema draft-04 is verbose and harder to read.
64const parameterSchema = {
65 type: 'object',
66 additionalProperties: false,
67 required: ['name', 'displayName', 'type', 'required'],
68 properties: {
69 name: {
70 type: 'string',
71 minLength: 1,
72 maxLength: 64,
73 pattern: PARAMETER_NAME_PATTERN,
74 },
75 displayName: { type: 'string', minLength: 1, maxLength: 200 },
76 description: { type: 'string', maxLength: 2000 },
77 type: { enum: [...ACTION_PARAMETER_TYPES] },
78 required: { type: 'boolean' },
79 options: { type: 'array', items: optionSchema, minItems: 1, maxItems: 100 },
80 min: { type: 'number' },
81 max: { type: 'number' },
82 maxLength: { type: 'integer', minimum: 1, maximum: 100000 },
83 defaultValue: {},
84 },
85} as const;
86
87const parameterListSchema = {
88 type: 'array',
89 items: parameterSchema,
90 maxItems: 50,
91} as const;
92
93const ajv = new Ajv({ allErrors: true, strictSchema: true });
94const validateStructure = ajv.compile(parameterListSchema);
95
96/**
97 * Throw a `CoopError` if `parameters` is not a valid action parameter list.
98 * Narrows `unknown` to `ActionParameter[]` on success.
99 */
100export function validateActionParameters(
101 parameters: unknown,
102): readonly ActionParameter[] {
103 if (parameters == null) {
104 return [];
105 }
106
107 if (!validateStructure(parameters)) {
108 throw makeInvalidParameterError(formatAjvErrors(validateStructure.errors));
109 }
110
111 const list = parameters as ActionParameter[];
112
113 const seenNames = new Set<string>();
114 for (const [index, param] of list.entries()) {
115 if (seenNames.has(param.name)) {
116 throw makeInvalidParameterError(
117 `parameters[${index}].name "${param.name}" is duplicated`,
118 );
119 }
120 seenNames.add(param.name);
121
122 validatePerTypeRules(param, index);
123 }
124
125 return list;
126}
127
128function validatePerTypeRules(param: ActionParameter, index: number): void {
129 switch (param.type) {
130 case 'STRING':
131 validateStringRules(param, index);
132 return;
133 case 'NUMBER':
134 validateNumberRules(param, index);
135 return;
136 case 'BOOLEAN':
137 validateBooleanRules(param, index);
138 return;
139 case 'SELECT':
140 case 'MULTISELECT':
141 validateSelectRules(param, index);
142 return;
143 default:
144 // AJV's `enum` keyword has already rejected unknown `type` values; this
145 // branch only exists to satisfy the exhaustiveness check.
146 assertUnreachable(param.type);
147 }
148}
149
150const at = (index: number, suffix: string) => `parameters[${index}].${suffix}`;
151
152function validateStringRules(param: ActionParameter, index: number): void {
153 if (param.options !== undefined) {
154 throw makeInvalidParameterError(
155 `${at(index, 'options')} is not allowed for STRING parameters`,
156 );
157 }
158 if (param.min !== undefined || param.max !== undefined) {
159 throw makeInvalidParameterError(
160 `${at(index, 'min/max')} is not allowed for STRING (use maxLength)`,
161 );
162 }
163 if (
164 param.defaultValue !== undefined &&
165 typeof param.defaultValue !== 'string'
166 ) {
167 throw makeInvalidParameterError(
168 `${at(index, 'defaultValue')} must be a string for STRING parameters`,
169 );
170 }
171 if (
172 param.maxLength !== undefined &&
173 typeof param.defaultValue === 'string' &&
174 param.defaultValue.length > param.maxLength
175 ) {
176 throw makeInvalidParameterError(
177 `${at(index, 'defaultValue')} exceeds maxLength`,
178 );
179 }
180 // An empty default on a required field would silently pass the runtime
181 // required-check. Reject at authoring time so the spec is internally
182 // consistent.
183 if (
184 param.required &&
185 typeof param.defaultValue === 'string' &&
186 param.defaultValue.trim() === ''
187 ) {
188 throw makeInvalidParameterError(
189 `${at(index, 'defaultValue')} cannot be empty when the parameter is required`,
190 );
191 }
192}
193
194function validateNumberRules(param: ActionParameter, index: number): void {
195 if (param.options !== undefined) {
196 throw makeInvalidParameterError(
197 `${at(index, 'options')} is not allowed for NUMBER parameters`,
198 );
199 }
200 if (param.maxLength !== undefined) {
201 throw makeInvalidParameterError(
202 `${at(index, 'maxLength')} is not allowed for NUMBER parameters`,
203 );
204 }
205 if (
206 param.min !== undefined &&
207 param.max !== undefined &&
208 param.min > param.max
209 ) {
210 throw makeInvalidParameterError(`${at(index, 'min')} must be <= max`);
211 }
212 if (param.defaultValue === undefined) return;
213 if (
214 typeof param.defaultValue !== 'number' ||
215 Number.isNaN(param.defaultValue)
216 ) {
217 throw makeInvalidParameterError(
218 `${at(index, 'defaultValue')} must be a number for NUMBER parameters`,
219 );
220 }
221 if (param.min !== undefined && param.defaultValue < param.min) {
222 throw makeInvalidParameterError(
223 `${at(index, 'defaultValue')} is below min`,
224 );
225 }
226 if (param.max !== undefined && param.defaultValue > param.max) {
227 throw makeInvalidParameterError(
228 `${at(index, 'defaultValue')} is above max`,
229 );
230 }
231}
232
233function validateBooleanRules(param: ActionParameter, index: number): void {
234 if (
235 param.options !== undefined ||
236 param.min !== undefined ||
237 param.max !== undefined ||
238 param.maxLength !== undefined
239 ) {
240 throw makeInvalidParameterError(
241 `${at(index, '')} only "defaultValue" is allowed alongside type=BOOLEAN`,
242 );
243 }
244 if (
245 param.defaultValue !== undefined &&
246 typeof param.defaultValue !== 'boolean'
247 ) {
248 throw makeInvalidParameterError(
249 `${at(index, 'defaultValue')} must be a boolean for BOOLEAN parameters`,
250 );
251 }
252}
253
254function validateSelectRules(param: ActionParameter, index: number): void {
255 if (param.options === undefined || param.options.length === 0) {
256 throw makeInvalidParameterError(
257 `${at(index, 'options')} is required for ${param.type} parameters`,
258 );
259 }
260 if (
261 param.min !== undefined ||
262 param.max !== undefined ||
263 param.maxLength !== undefined
264 ) {
265 throw makeInvalidParameterError(
266 `${at(index, '')} min/max/maxLength are not allowed for ${param.type} parameters`,
267 );
268 }
269 const optionValues = new Set<string>();
270 for (const [i, option] of param.options.entries()) {
271 if (optionValues.has(option.value)) {
272 throw makeInvalidParameterError(
273 `${at(index, `options[${i}].value`)} "${option.value}" is duplicated`,
274 );
275 }
276 optionValues.add(option.value);
277 }
278 if (param.defaultValue === undefined) return;
279 const ok =
280 param.type === 'SELECT'
281 ? typeof param.defaultValue === 'string' &&
282 optionValues.has(param.defaultValue)
283 : Array.isArray(param.defaultValue) &&
284 param.defaultValue.every(
285 (v) => typeof v === 'string' && optionValues.has(v),
286 );
287 if (!ok) {
288 throw makeInvalidParameterError(
289 param.type === 'SELECT'
290 ? `${at(index, 'defaultValue')} must be one of the option values`
291 : `${at(index, 'defaultValue')} must be an array of option values`,
292 );
293 }
294 // An empty MULTISELECT default on a required field would silently pass the
295 // runtime required-check. SELECT defaults of `''` are already rejected by
296 // the `optionValues.has` check above (option values must be minLength 1).
297 if (
298 param.required &&
299 param.type === 'MULTISELECT' &&
300 Array.isArray(param.defaultValue) &&
301 param.defaultValue.length === 0
302 ) {
303 throw makeInvalidParameterError(
304 `${at(index, 'defaultValue')} cannot be empty when the parameter is required`,
305 );
306 }
307}
308
309function formatAjvErrors(
310 errors: readonly ErrorObject[] | null | undefined,
311): string {
312 if (!errors || errors.length === 0) return 'invalid action parameters';
313 return errors
314 .map(
315 (err) => `${err.instancePath || '/'}: ${err.message ?? 'invalid value'}`,
316 )
317 .join('; ');
318}
319
320/**
321 * Recover a typed parameter list from the loose `JsonValue | null` stored in
322 * `actions.custom_mrt_api_params`. Designed to be defensive: silently drops
323 * any entry that doesn't validate so legacy rows written before the AJV-
324 * validated authoring path (PR 1) don't crash readers/executors.
325 *
326 * Use this anywhere you need to act on an action's parameter spec at
327 * execution time — distinct from `validateActionParameters`, which is the
328 * write-side AJV validator.
329 */
330export function parseStoredParameters(value: unknown): ActionParameter[] {
331 if (!Array.isArray(value)) return [];
332 const allowedTypes = ACTION_PARAMETER_TYPES as readonly string[];
333 const out: ActionParameter[] = [];
334 for (const raw of value) {
335 if (typeof raw !== 'object' || raw === null) continue;
336 const obj = raw as Record<string, unknown>;
337 const name = typeof obj.name === 'string' ? obj.name : null;
338 const displayName =
339 typeof obj.displayName === 'string' ? obj.displayName : null;
340 const typeRaw = typeof obj.type === 'string' ? obj.type : null;
341 if (name === null || displayName === null || typeRaw === null) continue;
342 if (!allowedTypes.includes(typeRaw)) continue;
343 const parsed: ActionParameter = {
344 name,
345 displayName,
346 type: typeRaw as ActionParameterType,
347 required: obj.required === true,
348 };
349 if (typeof obj.description === 'string')
350 parsed.description = obj.description;
351 if (Array.isArray(obj.options)) {
352 const options: ActionParameterOption[] = [];
353 for (const opt of obj.options) {
354 if (typeof opt !== 'object' || opt === null) continue;
355 const o = opt as Record<string, unknown>;
356 if (typeof o.value === 'string' && typeof o.label === 'string') {
357 options.push({ value: o.value, label: o.label });
358 }
359 }
360 if (options.length > 0) parsed.options = options;
361 }
362 if (typeof obj.min === 'number') parsed.min = obj.min;
363 if (typeof obj.max === 'number') parsed.max = obj.max;
364 if (typeof obj.maxLength === 'number') parsed.maxLength = obj.maxLength;
365 if ('defaultValue' in obj) parsed.defaultValue = obj.defaultValue;
366 out.push(parsed);
367 }
368 return out;
369}
370
371function makeInvalidParameterError(detail: string) {
372 return makeBadRequestError('Invalid action parameters', {
373 detail,
374 shouldErrorSpan: false,
375 });
376}
377
378/**
379 * Serialize a validated parameter list to the shape the DB driver expects for
380 * `actions.custom_mrt_api_params jsonb[]`. Returns `[]` (the column default)
381 * when the caller passed an empty list, so writes stay deterministic.
382 */
383export function serializeParameters(
384 parameters: readonly ActionParameter[],
385): JsonValue[] {
386 return parameters.map((param) => {
387 const out: Record<string, JsonValue> = {
388 name: param.name,
389 displayName: param.displayName,
390 type: param.type,
391 required: param.required,
392 };
393 if (param.description !== undefined) out.description = param.description;
394 if (param.options !== undefined) {
395 out.options = param.options.map((o) => ({
396 value: o.value,
397 label: o.label,
398 }));
399 }
400 if (param.min !== undefined) out.min = param.min;
401 if (param.max !== undefined) out.max = param.max;
402 if (param.maxLength !== undefined) out.maxLength = param.maxLength;
403 if (param.defaultValue !== undefined)
404 out.defaultValue = param.defaultValue as JsonValue;
405 return out;
406 });
407}