Mirrored from GitHub github.com/roostorg/coop
0

Configure Feed

Select the types of activity you want to include in your feed.

coop / server / services / moderationConfigService / modules / actionParametersValidation.ts
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}