From 32ef340f35702eb544c7499f8299184bc6016385 Mon Sep 17 00:00:00 2001 From: George Djabarov <39195835+djabarovgeorge@users.noreply.github.com> Date: Tue, 17 Dec 2024 17:37:42 +0200 Subject: [PATCH] refactor(api): refactor none-email control value preview usecase (#7303) --- .../render-email-output.usecase.ts | 4 +- .../workflows-v2/e2e/generate-preview.e2e.ts | 4 +- .../app/workflows-v2/generate-preview.e2e.ts | 27 +- .../shared/schemas/email-control.schema.ts | 18 +- .../shared/step-type-to-control.mapper.ts | 8 +- .../generate-preview.usecase.ts | 448 +++++++++++++++--- .../template-parser/liquid-parser.spec.ts | 61 ++- .../util/template-parser/liquid-parser.ts | 90 +++- libs/application-generic/src/utils/index.ts | 1 + .../utils/sanitize-preview-control-values.ts | 222 +++++++++ pnpm-lock.yaml | 68 ++- 11 files changed, 773 insertions(+), 178 deletions(-) create mode 100644 libs/application-generic/src/utils/sanitize-preview-control-values.ts diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/render-email-output.usecase.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/render-email-output.usecase.ts index 36309a69827..81ec6db1480 100644 --- a/apps/api/src/app/environments-v1/usecases/output-renderers/render-email-output.usecase.ts +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/render-email-output.usecase.ts @@ -5,7 +5,7 @@ import { Instrument, InstrumentUsecase } from '@novu/application-generic'; import isEmpty from 'lodash/isEmpty'; import { FullPayloadForRender, RenderCommand } from './render-command'; import { ExpandEmailEditorSchemaUsecase } from './expand-email-editor-schema.usecase'; -import { EmailStepControlZodSchema } from '../../../workflows-v2/shared'; +import { emailStepControlZodSchema } from '../../../workflows-v2/shared'; export class RenderEmailOutputCommand extends RenderCommand {} @@ -15,7 +15,7 @@ export class RenderEmailOutputUsecase { @InstrumentUsecase() async execute(renderCommand: RenderEmailOutputCommand): Promise { - const { body, subject } = EmailStepControlZodSchema.parse(renderCommand.controlValues); + const { body, subject } = emailStepControlZodSchema.parse(renderCommand.controlValues); if (isEmpty(body)) { return { subject, body: '' }; diff --git a/apps/api/src/app/workflows-v2/e2e/generate-preview.e2e.ts b/apps/api/src/app/workflows-v2/e2e/generate-preview.e2e.ts index 236c38d7ddb..0ecf30de17c 100644 --- a/apps/api/src/app/workflows-v2/e2e/generate-preview.e2e.ts +++ b/apps/api/src/app/workflows-v2/e2e/generate-preview.e2e.ts @@ -59,7 +59,7 @@ describe('Workflow Step Preview - POST /:workflowId/step/:stepId/preview', () => preview: { subject: 'Welcome {{subscriber.firstName}}', // cspell:disable-next-line - body: 'Hello {{subscriber.firstName}} {{subscriber.lastName}}, Welcome to {{PAYLOAD.ORGANIZATIONNAME | UPCASE}}!', + body: 'Hello {{subscriber.firstName}} {{subscriber.lastName}}, Welcome to {{PAYLOAD.ORGANIZATIONNAME}}!', }, type: 'in_app', }, @@ -69,7 +69,7 @@ describe('Workflow Step Preview - POST /:workflowId/step/:stepId/preview', () => lastName: '{{subscriber.lastName}}', }, payload: { - organizationName: '{{payload.organizationName | upcase}}', + organizationName: '{{payload.organizationName}}', }, }, }, diff --git a/apps/api/src/app/workflows-v2/generate-preview.e2e.ts b/apps/api/src/app/workflows-v2/generate-preview.e2e.ts index 88f9d3626ba..fd480ac9855 100644 --- a/apps/api/src/app/workflows-v2/generate-preview.e2e.ts +++ b/apps/api/src/app/workflows-v2/generate-preview.e2e.ts @@ -328,11 +328,9 @@ describe('Generate Preview', () => { if (previewResponseDto.result!.type !== 'sms') { throw new Error('Expected sms'); } - expect(previewResponseDto.result!.preview.body).to.contain('{{PAYLOAD.VARIABLENAME | UPCASE}}'); + expect(previewResponseDto.result!.preview.body).to.contain('{{PAYLOAD.VARIABLENAME}}'); expect(previewResponseDto.previewPayloadExample).to.exist; - expect(previewResponseDto?.previewPayloadExample?.payload?.variableName).to.equal( - '{{payload.variableName | upcase}}' - ); + expect(previewResponseDto?.previewPayloadExample?.payload?.variableName).to.equal('{{payload.variableName}}'); }); it('Should not fail if inApp is providing partial URL in redirect', async () => { @@ -413,26 +411,7 @@ describe('Generate Preview', () => { ); if (generatePreviewResponseDto.result?.type === ChannelTypeEnum.IN_APP) { - expect(generatePreviewResponseDto.result.preview.body).to.equal( - { - subject: `{{subscriber.firstName}} Hello, World! ${PLACEHOLDER_SUBJECT_INAPP}`, - body: `Hello, World! {{payload.placeholder.body}}`, - avatar: 'https://www.example.com/avatar.png', - primaryAction: { - label: '{{payload.secondaryUrl}}', - redirect: { - target: RedirectTargetEnum.BLANK, - }, - }, - secondaryAction: null, - redirect: { - target: RedirectTargetEnum.BLANK, - url: ' ', - }, - }.body - ); - expect(generatePreviewResponseDto.result.preview.primaryAction?.redirect?.url).to.be.ok; - expect(generatePreviewResponseDto.result.preview.primaryAction?.redirect?.url).to.contain('https'); + expect(generatePreviewResponseDto.result.preview.body).to.equal('Hello, World! {{payload.placeholder.body}}'); } }); }); diff --git a/apps/api/src/app/workflows-v2/shared/schemas/email-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/email-control.schema.ts index 41d3065de7d..897c4f88429 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/email-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/email-control.schema.ts @@ -1,20 +1,23 @@ import { JSONSchemaDto, UiComponentEnum, UiSchema, UiSchemaGroupEnum } from '@novu/shared'; - import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { skipControl } from './skip-control.schema'; +import { TipTapSchema } from '../../../environments-v1/usecases/output-renderers'; -export const EmailStepControlZodSchema = z +export const emailStepControlZodSchema = z .object({ skip: skipControl.schema, + /* + * todo: we need to validate the email editor (body) by type and not string, + * updating it to TipTapSchema will break the existing upsert issues generation + */ body: z.string().optional().default(''), subject: z.string().optional().default(''), }) .strict(); -export const emailStepControlSchema = zodToJsonSchema(EmailStepControlZodSchema) as JSONSchemaDto; - -export type EmailStepControlType = z.infer; +export const emailStepControlSchema = zodToJsonSchema(emailStepControlZodSchema) as JSONSchemaDto; +export type EmailStepControlType = z.infer; export const emailStepUiSchema: UiSchema = { group: UiSchemaGroupEnum.EMAIL, @@ -28,3 +31,8 @@ export const emailStepUiSchema: UiSchema = { skip: skipControl.uiSchema.properties.skip, }, }; + +export const emailStepControl = { + uiSchema: emailStepUiSchema, + schema: emailStepControlSchema, +}; diff --git a/apps/api/src/app/workflows-v2/shared/step-type-to-control.mapper.ts b/apps/api/src/app/workflows-v2/shared/step-type-to-control.mapper.ts index 0d737a2c2f0..663d6b10642 100644 --- a/apps/api/src/app/workflows-v2/shared/step-type-to-control.mapper.ts +++ b/apps/api/src/app/workflows-v2/shared/step-type-to-control.mapper.ts @@ -1,6 +1,6 @@ -import { ActionStepEnum, ChannelStepEnum, channelStepSchemas } from '@novu/framework/internal'; +import { ActionStepEnum, ChannelStepEnum } from '@novu/framework/internal'; import { ControlSchemas, JSONSchemaDto } from '@novu/shared'; -import { emailStepControlSchema, emailStepUiSchema, inAppControlSchema, inAppUiSchema } from './schemas'; +import { emailStepControl, inAppControlSchema, inAppUiSchema } from './schemas'; import { DelayTimeControlSchema, delayUiSchema } from './schemas/delay-control.schema'; import { DigestOutputJsonSchema, digestUiSchema } from './schemas/digest-control.schema'; import { smsStepControl } from './schemas/sms-control.schema'; @@ -20,8 +20,8 @@ export const stepTypeToControlSchema: Record; +}; + +type ProcessedControlResult = { + controlValues: Record; + variablesExample: Record | null; +}; + @Injectable() export class GeneratePreviewUsecase { constructor( - private legacyPreviewStepUseCase: PreviewStep, + private previewStepUsecase: PreviewStep, private buildStepDataUsecase: BuildStepDataUsecase, private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase, private readonly logger: PinoLogger, - private prepareAndValidateContentUsecase: PrepareAndValidateContentUsecase, - private buildPayloadSchema: BuildPayloadSchema + private buildPayloadSchema: BuildPayloadSchema, + private prepareAndValidateContentUsecase: PrepareAndValidateContentUsecase ) {} @InstrumentUsecase() async execute(command: GeneratePreviewCommand): Promise { try { - const { previewPayload: commandVariablesExample, controlValues: commandControlValues } = - command.generatePreviewRequestDto; - const stepData = await this.getStepData(command); - const controlValues = commandControlValues || stepData.controls.values || {}; - const workflow = await this.findWorkflow(command); - const payloadSchema = await this.buildPayloadSchema.execute( - BuildPayloadSchemaCommand.create({ - environmentId: command.user.environmentId, - organizationId: command.user.organizationId, - userId: command.user._id, - workflowId: command.workflowIdOrInternalId, - controlValues, - }) - ); + const { + stepData, + controlValues: initialControlValues, + variableSchema, + workflow, + } = await this.initializePreviewContext(command); - const variableSchema = this.buildVariablesSchema(stepData.variables, payloadSchema); - const preparedAndValidatedContent = await this.prepareAndValidateContentUsecase.execute({ - user: command.user, - previewPayloadFromDto: commandVariablesExample, - controlValues, - controlDataSchema: stepData.controls.dataSchema || {}, + const sanitizedValidatedControls = sanitizePreviewControlValues(initialControlValues, stepData.type); + + if (!sanitizedValidatedControls) { + throw new Error( + // eslint-disable-next-line max-len + 'Control values normalization failed: The normalizeControlValues function requires maintenance to sanitize the provided type or data structure correctly' + ); + } + + const destructuredControlValues = this.destructureControlValues(sanitizedValidatedControls); + + const { variablesExample: tiptapVariablesExample, controlValues: tiptapControlValues } = + await this.handleTipTapControl( + destructuredControlValues.tiptapControlValues, + command, + stepData, + variableSchema + ); + const { variablesExample: simpleVariablesExample, controlValues: simpleControlValues } = this.handleSimpleControl( + destructuredControlValues.simpleControlValues, variableSchema, - }); - const variablesExample = this.buildVariablesExample( workflow, - preparedAndValidatedContent.finalPayload, - commandVariablesExample + command.generatePreviewRequestDto.previewPayload ); + const previewData = { + variablesExample: _.merge({}, tiptapVariablesExample || {}, simpleVariablesExample || {}), + controlValues: { ...tiptapControlValues, ...simpleControlValues }, + }; const executeOutput = await this.executePreviewUsecase( command, stepData, - variablesExample, - preparedAndValidatedContent.finalControlValues + previewData.variablesExample, + previewData.controlValues ); return { @@ -84,7 +111,7 @@ export class GeneratePreviewUsecase { preview: executeOutput.outputs as any, type: stepData.type as unknown as ChannelTypeEnum, }, - previewPayloadExample: variablesExample, + previewPayloadExample: previewData.variablesExample, }; } catch (error) { this.logger.error( @@ -96,7 +123,6 @@ export class GeneratePreviewUsecase { `Unexpected error while generating preview`, LOG_CONTEXT ); - if (process.env.SENTRY_DSN) { captureException(error); } @@ -111,39 +137,149 @@ export class GeneratePreviewUsecase { } } - /** - * Merges the payload schema into the variables schema to enable proper validation - * and sanitization of control values in the prepareAndValidateContentUsecase. - */ - @Instrument() - private buildVariablesSchema(variables: Record, payloadSchema: JSONSchemaDto) { - if (Object.keys(payloadSchema).length === 0) { - return variables; + private async safeAttemptToParseEmailSchema( + tiptapControl: string, + command: GeneratePreviewCommand, + controlValues: Record, + controlSchema: Record, + variableSchema: Record + ): Promise | null> { + if (typeof tiptapControl !== 'string') { + return null; } - return _.merge(variables, { properties: { payload: payloadSchema } }); + try { + const preparedAndValidatedContent = await this.prepareAndValidateContentUsecase.execute({ + user: command.user, + previewPayloadFromDto: command.generatePreviewRequestDto.previewPayload, + controlValues, + controlDataSchema: controlSchema || {}, + variableSchema, + }); + + return preparedAndValidatedContent.finalPayload as Record; + } catch (e) { + return null; + } } - @Instrument() - private buildVariablesExample( + private async handleTipTapControl( + tiptapControlValue: { + emailEditor?: string | null; + body?: string | null; + } | null, + command: GeneratePreviewCommand, + stepData: StepDataDto, + variableSchema: Record + ): Promise { + if (!tiptapControlValue || (!tiptapControlValue?.emailEditor && !tiptapControlValue?.body)) { + return { + variablesExample: null, + controlValues: tiptapControlValue as Record, + }; + } + + const emailVariables = await this.safeAttemptToParseEmailSchema( + tiptapControlValue?.emailEditor || tiptapControlValue?.body || '', + command, + tiptapControlValue, + stepData.controls.dataSchema || {}, + variableSchema + ); + + return { + variablesExample: emailVariables, + controlValues: tiptapControlValue, + }; + } + + private handleSimpleControl( + controlValues: Record, + variableSchema: Record, workflow: WorkflowInternalResponseDto, - finalPayload?: PreviewPayload, - commandVariablesExample?: PreviewPayload | undefined - ) { - if (workflow.origin !== WorkflowOriginEnum.EXTERNAL) { - return finalPayload; + commandVariablesExample: PreviewPayload | undefined + ): ProcessedControlResult { + const variables = this.processControlValueVariables(controlValues, variableSchema); + const processedControlValues = this.fixControlValueInvalidVariables(controlValues, variables.invalid); + const extractedTemplateVariables = variables.valid.map((variable) => variable.name); + const payloadVariableExample = + workflow.origin === WorkflowOriginEnum.EXTERNAL + ? createMockObjectFromSchema({ + type: 'object', + properties: { payload: workflow.payloadSchema }, + }) + : {}; + + if (extractedTemplateVariables.length === 0) { + return { + variablesExample: payloadVariableExample, + controlValues: processedControlValues, + }; } - const examplePayloadSchema = createMockObjectFromSchema({ - type: 'object', - properties: { payload: workflow.payloadSchema }, + const variablesExample: Record = pathsToObject(extractedTemplateVariables, { + valuePrefix: '{{', + valueSuffix: '}}', }); - if (!examplePayloadSchema || Object.keys(examplePayloadSchema).length === 0) { - return finalPayload; + const variablesExampleForPreview = _.merge(variablesExample, payloadVariableExample, commandVariablesExample || {}); + + return { + variablesExample: variablesExampleForPreview, + controlValues: processedControlValues, + }; + } + + private async initializePreviewContext(command: GeneratePreviewCommand) { + const stepData = await this.getStepData(command); + const controlValues = command.generatePreviewRequestDto.controlValues || stepData.controls.values || {}; + const workflow = await this.findWorkflow(command); + const variableSchema = await this.buildVariablesSchema(stepData.variables, command, controlValues); + + return { stepData, controlValues, variableSchema, workflow }; + } + + private processControlValueVariables( + controlValues: Record, + variableSchema: Record + ): { + valid: Variable[]; + invalid: Variable[]; + } { + const { validVariables, invalidVariables } = extractLiquidTemplateVariables(JSON.stringify(controlValues)); + + const { validVariables: validSchemaVariables, invalidVariables: invalidSchemaVariables } = identifyUnknownVariables( + variableSchema, + validVariables + ); + + return { + valid: validSchemaVariables, + invalid: [...invalidVariables, ...invalidSchemaVariables], + }; + } + + @Instrument() + private async buildVariablesSchema( + variables: Record, + command: GeneratePreviewCommand, + controlValues: Record + ) { + const payloadSchema = await this.buildPayloadSchema.execute( + BuildPayloadSchemaCommand.create({ + environmentId: command.user.environmentId, + organizationId: command.user.organizationId, + userId: command.user._id, + workflowId: command.workflowIdOrInternalId, + controlValues, + }) + ); + + if (Object.keys(payloadSchema).length === 0) { + return variables; } - return _.merge(finalPayload as Record, examplePayloadSchema, commandVariablesExample || {}); + return _.merge(variables, { properties: { payload: payloadSchema } }); } @Instrument() @@ -180,7 +316,7 @@ export class GeneratePreviewUsecase { ) { const state = buildState(hydratedPayload.steps); try { - return await this.legacyPreviewStepUseCase.execute( + return await this.previewStepUsecase.execute( PreviewStepCommand.create({ payload: hydratedPayload.payload || {}, subscriber: hydratedPayload.subscriber, @@ -202,6 +338,55 @@ export class GeneratePreviewUsecase { } } } + + private destructureControlValues(controlValues: Record): DestructuredControlValues { + try { + const localControlValue = _.cloneDeep(controlValues); + let tiptapControlString: string | null = null; + + if (isTipTapNode(localControlValue.emailEditor)) { + tiptapControlString = localControlValue.emailEditor; + delete localControlValue.emailEditor; + + return { tiptapControlValues: { emailEditor: tiptapControlString }, simpleControlValues: localControlValue }; + } + + if (isTipTapNode(localControlValue.body)) { + tiptapControlString = localControlValue.body; + delete localControlValue.body; + + return { tiptapControlValues: { body: tiptapControlString }, simpleControlValues: localControlValue }; + } + + return { tiptapControlValues: null, simpleControlValues: localControlValue }; + } catch (error) { + this.logger.error({ error }, 'Failed to extract TipTap control', LOG_CONTEXT); + + return { tiptapControlValues: null, simpleControlValues: controlValues }; + } + } + + private fixControlValueInvalidVariables( + controlValues: Record, + invalidVariables: Variable[] + ): Record { + try { + let controlValuesString = JSON.stringify(controlValues); + + for (const invalidVariable of invalidVariables) { + if (!controlValuesString.includes(invalidVariable.template)) { + continue; + } + + const EMPTY_STRING = ''; + controlValuesString = replaceAll(controlValuesString, invalidVariable.template, EMPTY_STRING); + } + + return JSON.parse(controlValuesString) as Record; + } catch (error) { + return controlValues; + } + } } function buildState(steps: Record | undefined): FrameworkPreviousStepsOutputState[] { @@ -241,3 +426,154 @@ class FrameworkError { message: string; name: string; } + +/** + * Validates liquid template variables against a schema, the result is an object with valid and invalid variables + * @example + * const variables = [ + * { name: 'subscriber.firstName' }, + * { name: 'subscriber.orderId' } + * ]; + * const schema = { + * properties: { + * subscriber: { + * properties: { + * firstName: { type: 'string' } + * } + * } + * } + * }; + * const invalid = [{ name: 'unknown.variable' }]; + * + * validateVariablesAgainstSchema(variables, schema, invalid); + * // Returns: + * // { + * // validVariables: [{ name: 'subscriber.firstName' }], + * // invalidVariables: [{ name: 'unknown.variable' }, { name: 'subscriber.orderId' }] + * // } + */ +function identifyUnknownVariables( + variableSchema: Record, + validVariables: Variable[] +): TemplateParseResult { + const validVariablesCopy: Variable[] = _.cloneDeep(validVariables); + + const result = validVariablesCopy.reduce( + (acc, variable: Variable) => { + const parts = variable.name.split('.'); + let isValid = true; + let currentPath = 'properties'; + + for (const part of parts) { + currentPath += `.${part}`; + const valueSearch = _.get(variableSchema, currentPath); + + currentPath += '.properties'; + const propertiesSearch = _.get(variableSchema, currentPath); + + if (valueSearch === undefined && propertiesSearch === undefined) { + isValid = false; + break; + } + } + + if (isValid) { + acc.validVariables.push(variable); + } else { + acc.invalidVariables.push({ + name: variable.template, + context: variable.context, + message: 'Variable is not supported', + template: variable.template, + }); + } + + return acc; + }, + { + validVariables: [] as Variable[], + invalidVariables: [] as Variable[], + } as TemplateParseResult + ); + + return result; +} + +/** + * Fixes invalid Liquid template variables for preview by replacing them with error messages. + * + * @example + * // Input controlValues: + * { "message": "Hello {{invalid.var}}" } + * + * // Output: + * { "message": "Hello [[Invalid Variable: invalid.var]]" } + */ +function replaceAll(text: string, searchValue: string, replaceValue: string): string { + return _.replace(text, new RegExp(_.escapeRegExp(searchValue), 'g'), replaceValue); +} + +/** + * + * @param value minimal tiptap object from the client is + * { + * "type": "doc", + * "content": [ + * { + * "type": "paragraph", + * "attrs": { + * "textAlign": "left" + * }, + * "content": [ + * { + * "type": "text", + * "text": " " + * } + * ] + * } + *] + *} + */ +export function isTipTapNode(value: unknown): value is string { + let localValue = value; + if (typeof localValue === 'string') { + try { + localValue = JSON.parse(localValue); + } catch { + return false; + } + } + + if (!localValue || typeof localValue !== 'object') return false; + + const doc = localValue as TipTapNode; + + // TODO check if validate type === doc is enough + if (doc.type !== 'doc' || !Array.isArray(doc.content)) return false; + + return true; + + /* + * TODO check we need to validate the content + * return doc.content.every((node) => isValidTipTapContent(node)); + */ +} + +function isValidTipTapContent(node: unknown): boolean { + if (!node || typeof node !== 'object') return false; + const content = node as TipTapNode; + if (typeof content.type !== 'string') return false; + if (content.attrs !== undefined && (typeof content.attrs !== 'object' || content.attrs === null)) { + return false; + } + if (content.text !== undefined && typeof content.text !== 'string') { + return false; + } + if (content.content !== undefined) { + if (!Array.isArray(content.content)) return false; + + return content.content.every((child) => isValidTipTapContent(child)); + } + + return true; +} diff --git a/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.spec.ts b/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.spec.ts index c794cca8190..ce717cd3481 100644 --- a/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.spec.ts +++ b/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.spec.ts @@ -2,12 +2,15 @@ import { expect } from 'chai'; import { extractLiquidTemplateVariables } from './liquid-parser'; describe('parseLiquidVariables', () => { - it('should extract simple variable names', () => { + it('should not extract variable without namespace', () => { const template = '{{name}} {{age}}'; - const { validVariables } = extractLiquidTemplateVariables(template); + const { validVariables, invalidVariables } = extractLiquidTemplateVariables(template); const validVariablesNames = validVariables.map((variable) => variable.name); - expect(validVariablesNames).to.have.members(['name', 'age']); + expect(validVariablesNames).to.have.members([]); + expect(invalidVariables).to.have.lengthOf(2); + expect(invalidVariables[0].name).to.equal('{{name}}'); + expect(invalidVariables[1].name).to.equal('{{age}}'); }); it('should extract nested object paths', () => { @@ -19,73 +22,87 @@ describe('parseLiquidVariables', () => { }); it('should handle multiple occurrences of the same variable', () => { - const template = '{{user.name}} {{user.name}} {{user.name}}'; - const { validVariables } = extractLiquidTemplateVariables(template); + const template = '{{user.name}} {{user.name}} {{user.name}} {{invalid..foo}} {{invalid..foo}}'; + const { validVariables, invalidVariables } = extractLiquidTemplateVariables(template); const validVariablesNames = validVariables.map((variable) => variable.name); expect(validVariablesNames).to.have.members(['user.name']); + expect(invalidVariables).to.have.lengthOf(1); + expect(invalidVariables[0].name).to.equal('{{invalid..foo}}'); }); it('should handle mixed content with HTML and variables', () => { const template = '
Hello {{user.name}}
{{status}}'; - const { validVariables } = extractLiquidTemplateVariables(template); + const { validVariables, invalidVariables } = extractLiquidTemplateVariables(template); const validVariablesNames = validVariables.map((variable) => variable.name); - expect(validVariablesNames).to.have.members(['user.name', 'status']); + expect(validVariablesNames).to.have.members(['user.name']); + expect(invalidVariables).to.have.lengthOf(1); + expect(invalidVariables[0].name).to.equal('{{status}}'); }); it('should handle whitespace in template syntax', () => { - const template = '{{ user.name }} {{ status }}'; + const template = '{{ user.name }}'; const { validVariables } = extractLiquidTemplateVariables(template); const validVariablesNames = validVariables.map((variable) => variable.name); - expect(validVariablesNames).to.have.members(['user.name', 'status']); + expect(validVariablesNames).to.have.members(['user.name']); }); it('should handle empty template string', () => { const template = ''; - const { validVariables } = extractLiquidTemplateVariables(template); + const { validVariables, invalidVariables } = extractLiquidTemplateVariables(template); expect(validVariables).to.have.lengthOf(0); + expect(invalidVariables).to.have.lengthOf(0); }); it('should handle template with no variables', () => { const template = 'Hello World!'; - const { validVariables } = extractLiquidTemplateVariables(template); + const { validVariables, invalidVariables } = extractLiquidTemplateVariables(template); expect(validVariables).to.have.lengthOf(0); + expect(invalidVariables).to.have.lengthOf(0); }); it('should handle special characters in variable names', () => { - const template = '{{special_var_1}} {{data-point}}'; + const template = '{{subscriber.special_var_1}} {{subscriber.data-point}}'; const { validVariables } = extractLiquidTemplateVariables(template); const validVariablesNames = validVariables.map((variable) => variable.name); - expect(validVariablesNames).to.have.members(['special_var_1', 'data-point']); + expect(validVariablesNames).to.have.members(['subscriber.special_var_1', 'subscriber.data-point']); + }); + + it('should handle whitespace in between template syntax', () => { + const template = '{{ user. name }}'; + const { validVariables } = extractLiquidTemplateVariables(template); + + expect(validVariables).to.have.lengthOf(1); + expect(validVariables[0].name).to.equal('user.name'); }); describe('Error handling', () => { it('should handle invalid liquid syntax gracefully', () => { - const { validVariables: variables, invalidVariables: errors } = extractLiquidTemplateVariables( + const { validVariables, invalidVariables } = extractLiquidTemplateVariables( '{{invalid..syntax}} {{invalid2..syntax}}' ); - expect(variables).to.have.lengthOf(0); - expect(errors).to.have.lengthOf(2); - expect(errors[0].message).to.contain('expected "|" before filter'); - expect(errors[0].name).to.equal('{{invalid..syntax}}'); - expect(errors[1].name).to.equal('{{invalid2..syntax}}'); + expect(validVariables).to.have.lengthOf(0); + expect(invalidVariables).to.have.lengthOf(2); + expect(invalidVariables[0].message).to.contain('expected "|" before filter'); + expect(invalidVariables[0].name).to.equal('{{invalid..syntax}}'); + expect(invalidVariables[1].name).to.equal('{{invalid2..syntax}}'); }); it('should handle invalid liquid syntax gracefully, return valid variables', () => { - const { validVariables, invalidVariables: errors } = extractLiquidTemplateVariables( + const { validVariables, invalidVariables } = extractLiquidTemplateVariables( '{{subscriber.name}} {{invalid..syntax}}' ); const validVariablesNames = validVariables.map((variable) => variable.name); expect(validVariablesNames).to.have.members(['subscriber.name']); - expect(errors[0].message).to.contain('expected "|" before filter'); - expect(errors[0].name).to.equal('{{invalid..syntax}}'); + expect(invalidVariables[0].message).to.contain('expected "|" before filter'); + expect(invalidVariables[0].name).to.equal('{{invalid..syntax}}'); }); it('should handle undefined input gracefully', () => { diff --git a/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts b/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts index cec68e1c16b..8cba93edd84 100644 --- a/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts +++ b/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts @@ -12,6 +12,7 @@ export type Variable = { context?: string; message?: string; name: string; + template: string; }; export type TemplateParseResult = { @@ -75,62 +76,103 @@ export function extractLiquidTemplateVariables(template: string): TemplateParseR } function processLiquidRawOutput(rawOutputs: string[]): TemplateParseResult { - const validVariables = new Set(); + const validVariables: Variable[] = []; const invalidVariables: Variable[] = []; + const processedVariables = new Set(); + + function addVariable(variable: Variable, isValid: boolean) { + if (!processedVariables.has(variable.name)) { + processedVariables.add(variable.name); + (isValid ? validVariables : invalidVariables).push(variable); + } + } for (const rawOutput of rawOutputs) { try { - const parsedVars = parseByLiquid(rawOutput); - parsedVars.forEach((variable) => validVariables.add(variable)); + const result = parseByLiquid(rawOutput); + result.validVariables.forEach((variable) => addVariable(variable, true)); + result.invalidVariables.forEach((variable) => addVariable(variable, false)); } catch (error: unknown) { if (isLiquidErrors(error)) { - invalidVariables.push( - ...error.errors.map((e: RenderError) => ({ - context: e.context, - message: e.message, - name: rawOutput, - })) - ); + error.errors.forEach((e: RenderError) => { + addVariable( + { + name: rawOutput, + message: e.message, + context: e.context, + template: rawOutput, + }, + false + ); + }); } } } - return { - validVariables: [...validVariables].map((name) => ({ name })), - invalidVariables, - }; + return { validVariables, invalidVariables }; } -function parseByLiquid(expression: string): Set { - const variables = new Set(); +function parseByLiquid(rawOutput: string): TemplateParseResult { + const validVariables: Variable[] = []; + const invalidVariables: Variable[] = []; const engine = new Liquid(LIQUID_CONFIG); - const parsed = engine.parse(expression) as unknown as Template[]; + const parsed = engine.parse(rawOutput) as unknown as Template[]; parsed.forEach((template: Template) => { if (isOutputToken(template)) { - const props = extractValidProps(template); - if (props.length > 0) { - variables.add(props.join('.')); + const result = extractProps(template); + + if (result.valid && result.props.length > 0) { + validVariables.push({ name: result.props.join('.'), template: rawOutput }); + } + + if (!result.valid) { + invalidVariables.push({ name: template?.token?.input, message: result.error, template: rawOutput }); } } }); - return variables; + return { validVariables, invalidVariables }; } function isOutputToken(template: Template): boolean { return template.token?.constructor.name === 'OutputToken'; } -function extractValidProps(template: any): string[] { +function extractProps(template: any): { valid: boolean; props: string[]; error?: string } { const initial = template.value?.initial; - if (!initial?.postfix?.[0]?.props) return []; + if (!initial?.postfix?.[0]?.props) return { valid: true, props: [] }; + + /** + * If initial.postfix length is greater than 1, it means the variable contains spaces + * which is not supported in Novu's variable syntax. + * + * Example: + * Valid: {{user.firstName}} + * Invalid: {{user.first name}} - postfix length would be 2 due to space + */ + if (initial.postfix.length > 1) { + return { valid: false, props: [], error: 'Novu does not support variables with spaces' }; + } const validProps: string[] = []; + for (const prop of initial.postfix[0].props) { if (prop.constructor.name !== 'IdentifierToken') break; validProps.push(prop.content); } - return validProps; + /** + * If validProps length is 1, it means the variable has no namespace which is not + * supported in Novu's variable syntax. Variables must be namespaced. + * + * Example: + * Valid: {{user.firstName}} - Has namespace 'user' + * Invalid: {{firstName}} - No namespace + */ + if (validProps.length === 1) { + return { valid: false, props: [], error: 'Novu variables must include a namespace (e.g. user.firstName)' }; + } + + return { valid: true, props: validProps }; } diff --git a/libs/application-generic/src/utils/index.ts b/libs/application-generic/src/utils/index.ts index 11f207996b1..a10c9bfa848 100644 --- a/libs/application-generic/src/utils/index.ts +++ b/libs/application-generic/src/utils/index.ts @@ -12,3 +12,4 @@ export * from './subscriber'; export * from './variants'; export * from './deepmerge'; export * from './generate-id'; +export * from './sanitize-preview-control-values'; diff --git a/libs/application-generic/src/utils/sanitize-preview-control-values.ts b/libs/application-generic/src/utils/sanitize-preview-control-values.ts new file mode 100644 index 00000000000..03d19c4e6fc --- /dev/null +++ b/libs/application-generic/src/utils/sanitize-preview-control-values.ts @@ -0,0 +1,222 @@ +const EMPTY_TIP_TAP_OBJECT = JSON.stringify({ + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { textAlign: 'left' }, + content: [{ type: 'text', text: ' ' }], + }, + ], +}); +const WHITESPACE = ' '; + +type Redirect = { + url: string; + target: '_self' | '_blank' | '_parent' | '_top' | '_unfencedTop'; +}; + +type Action = { + label?: string; + redirect?: Redirect; +}; + +type LookBackWindow = { + amount: number; + unit: string; +}; + +function sanitizeRedirect(redirect: Redirect) { + if (!redirect.url || !redirect.target) { + return undefined; + } + + return { + url: redirect.url || 'https://example.com', + target: redirect.target || '_self', + }; +} + +function sanitizeAction(action: Action) { + if (!action?.label) { + return undefined; + } + + return { + label: action.label, + redirect: sanitizeRedirect(action.redirect), + }; +} + +function sanitizeInApp(controlValues: Record) { + if (!controlValues) return controlValues; + + const normalized: Record = { + subject: controlValues.subject || null, + body: + (controlValues.body as string)?.length === 0 + ? WHITESPACE + : controlValues.body, + avatar: controlValues.avatar || null, + primaryAction: null, + secondaryAction: null, + redirect: null, + data: controlValues.data || null, + }; + + if (controlValues.primaryAction) { + normalized.primaryAction = sanitizeAction( + controlValues.primaryAction as Action, + ); + } + + if (controlValues.secondaryAction) { + normalized.secondaryAction = sanitizeAction( + controlValues.secondaryAction as Action, + ); + } + + if (controlValues.redirect) { + normalized.redirect = sanitizeRedirect(controlValues.redirect as Redirect); + } + + if (typeof normalized === 'object' && normalized !== null) { + return Object.fromEntries( + Object.entries(normalized).filter(([_, value]) => value !== null), + ); + } + + return normalized; +} + +function sanitizeEmail(controlValues: Record) { + if (!controlValues) return controlValues; + + const emailControls: Record = {}; + + /* + * if (controlValues.body != null) { + * emailControls.body = controlValues.body || ''; + * } + */ + emailControls.subject = controlValues.subject || ''; + emailControls.body = controlValues.body || EMPTY_TIP_TAP_OBJECT; + emailControls.data = controlValues.data || null; + + return emailControls; +} + +function sanitizeSms(controlValues: Record) { + if (!controlValues) return controlValues; + + return { + body: controlValues.body || '', + data: controlValues.data || null, + }; +} + +function sanitizePush(controlValues: Record) { + if (!controlValues) return controlValues; + + const mappedValues = { + subject: controlValues.subject || '', + body: controlValues.body || '', + data: controlValues.data || null, + }; + + if (typeof mappedValues === 'object' && mappedValues !== null) { + return Object.fromEntries( + Object.entries(mappedValues).filter(([_, value]) => value !== null), + ); + } + + return mappedValues; +} + +function sanitizeChat(controlValues: Record) { + if (!controlValues) return controlValues; + + return { + body: controlValues.body || '', + data: controlValues.data || null, + }; +} + +function sanitizeDigest(controlValues: Record) { + if (!controlValues) return controlValues; + + const mappedValues = { + cron: controlValues.cron || '', + amount: controlValues.amount || 0, + unit: controlValues.unit || '', + digestKey: controlValues.digestKey || '', + data: controlValues.data || null, + lookBackWindow: controlValues.lookBackWindow + ? { + amount: (controlValues.lookBackWindow as LookBackWindow).amount || 0, + unit: (controlValues.lookBackWindow as LookBackWindow).unit || '', + } + : null, + }; + + if (typeof mappedValues === 'object' && mappedValues !== null) { + return Object.fromEntries( + Object.entries(mappedValues).filter(([_, value]) => value !== null), + ); + } + + return mappedValues; +} + +/** + * Sanitizes control values received from client-side forms into a clean minimal object. + * This function processes potentially invalid form data that may contain default/placeholder values + * and transforms it into a standardized format suitable for preview generation. + * + * @example + * // Input from form with default values: + * { + * subject: "Hello", + * body: null, + * unusedField: "test" + * } + * + * // Normalized output: + * { + * subject: "Hello", + * body: " " + * } + * + */ +export function sanitizePreviewControlValues( + controlValues: Record, + stepType: string, +): Record | null { + if (!controlValues) { + return null; + } + let normalizedValues: Record; + switch (stepType) { + case 'in_app': + normalizedValues = sanitizeInApp(controlValues); + break; + case 'email': + normalizedValues = sanitizeEmail(controlValues); + break; + case 'sms': + normalizedValues = sanitizeSms(controlValues); + break; + case 'push': + normalizedValues = sanitizePush(controlValues); + break; + case 'chat': + normalizedValues = sanitizeChat(controlValues); + break; + case 'digest': + normalizedValues = sanitizeDigest(controlValues); + break; + default: + normalizedValues = controlValues; + } + + return normalizedValues; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2427c182cbd..9181613d4c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20506,7 +20506,7 @@ packages: resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} compose-function@3.0.3: - resolution: {integrity: sha512-xzhzTJ5eC+gmIzvZq+C3kCJHsp9os6tJkrigDRZclyGtOKINbZtE8n1Tzmeh32jW+BUDPbvZpibwvJHBLGMVwg==} + resolution: {integrity: sha1-ntZ18TzFRQHTCVCkhv9qe6OrGF8=} compressible@2.0.18: resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} @@ -23410,7 +23410,7 @@ packages: optional: true fresh@0.5.2: - resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + resolution: {integrity: sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=} engines: {node: '>= 0.6'} from2@2.3.0: @@ -31839,7 +31839,7 @@ packages: engines: {node: '>=10'} serve-favicon@2.5.0: - resolution: {integrity: sha512-FMW2RvqNr03x+C0WxTyu6sOv21oOjkq5j8tjquWccwa6ScNyGFOGJVpuS1NmTVGBAHS07xnSKotgf2ehQmf9iA==} + resolution: {integrity: sha1-k10kDN/g9YBTB/3+ln2IlCosvPA=} engines: {node: '>= 0.8.0'} serve-index@1.9.1: @@ -33254,7 +33254,7 @@ packages: resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} toposort@2.0.2: - resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==} + resolution: {integrity: sha1-riF2gXXRVZ1IvvNUILL0li8JwzA=} totalist@1.1.0: resolution: {integrity: sha512-gduQwd1rOdDMGxFG1gEvhV88Oirdo2p+KjoYFU7k2g+i7n6AFFbDQ5kMPUsW0pNbfQsB/cwXvT1i4Bue0s9g5g==} @@ -35837,8 +35837,8 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.575.0 - '@aws-sdk/client-sts': 3.575.0(@aws-sdk/client-sso-oidc@3.575.0) + '@aws-sdk/client-sso-oidc': 3.575.0(@aws-sdk/client-sts@3.575.0) + '@aws-sdk/client-sts': 3.575.0 '@aws-sdk/core': 3.575.0 '@aws-sdk/credential-provider-node': 3.575.0(@aws-sdk/client-sso-oidc@3.575.0)(@aws-sdk/client-sts@3.575.0) '@aws-sdk/middleware-host-header': 3.575.0 @@ -36039,8 +36039,8 @@ snapshots: '@aws-crypto/sha1-browser': 3.0.0 '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.575.0 - '@aws-sdk/client-sts': 3.575.0(@aws-sdk/client-sso-oidc@3.575.0) + '@aws-sdk/client-sso-oidc': 3.575.0(@aws-sdk/client-sts@3.575.0) + '@aws-sdk/client-sts': 3.575.0 '@aws-sdk/core': 3.575.0 '@aws-sdk/credential-provider-node': 3.575.0(@aws-sdk/client-sso-oidc@3.575.0)(@aws-sdk/client-sts@3.575.0) '@aws-sdk/middleware-bucket-endpoint': 3.575.0 @@ -36266,11 +36266,11 @@ snapshots: - aws-crt optional: true - '@aws-sdk/client-sso-oidc@3.575.0': + '@aws-sdk/client-sso-oidc@3.575.0(@aws-sdk/client-sts@3.575.0)': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sts': 3.575.0(@aws-sdk/client-sso-oidc@3.575.0) + '@aws-sdk/client-sts': 3.575.0 '@aws-sdk/core': 3.575.0 '@aws-sdk/credential-provider-node': 3.575.0(@aws-sdk/client-sso-oidc@3.575.0)(@aws-sdk/client-sts@3.575.0) '@aws-sdk/middleware-host-header': 3.575.0 @@ -36309,6 +36309,7 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.7.0 transitivePeerDependencies: + - '@aws-sdk/client-sts' - aws-crt '@aws-sdk/client-sso-oidc@3.637.0(@aws-sdk/client-sts@3.637.0)': @@ -36693,11 +36694,11 @@ snapshots: - aws-crt optional: true - '@aws-sdk/client-sts@3.575.0(@aws-sdk/client-sso-oidc@3.575.0)': + '@aws-sdk/client-sts@3.575.0': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.575.0 + '@aws-sdk/client-sso-oidc': 3.575.0(@aws-sdk/client-sts@3.575.0) '@aws-sdk/core': 3.575.0 '@aws-sdk/credential-provider-node': 3.575.0(@aws-sdk/client-sso-oidc@3.575.0)(@aws-sdk/client-sts@3.575.0) '@aws-sdk/middleware-host-header': 3.575.0 @@ -36736,7 +36737,6 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.7.0 transitivePeerDependencies: - - '@aws-sdk/client-sso-oidc' - aws-crt '@aws-sdk/client-sts@3.637.0': @@ -36966,7 +36966,7 @@ snapshots: '@aws-sdk/credential-provider-ini@3.575.0(@aws-sdk/client-sso-oidc@3.575.0)(@aws-sdk/client-sts@3.575.0)': dependencies: - '@aws-sdk/client-sts': 3.575.0(@aws-sdk/client-sso-oidc@3.575.0) + '@aws-sdk/client-sts': 3.575.0 '@aws-sdk/credential-provider-env': 3.575.0 '@aws-sdk/credential-provider-process': 3.575.0 '@aws-sdk/credential-provider-sso': 3.575.0(@aws-sdk/client-sso-oidc@3.575.0) @@ -37277,7 +37277,7 @@ snapshots: '@aws-sdk/credential-provider-web-identity@3.575.0(@aws-sdk/client-sts@3.575.0)': dependencies: - '@aws-sdk/client-sts': 3.575.0(@aws-sdk/client-sso-oidc@3.575.0) + '@aws-sdk/client-sts': 3.575.0 '@aws-sdk/types': 3.575.0 '@smithy/property-provider': 3.1.3 '@smithy/types': 3.3.0 @@ -37798,7 +37798,7 @@ snapshots: '@aws-sdk/token-providers@3.575.0(@aws-sdk/client-sso-oidc@3.575.0)': dependencies: - '@aws-sdk/client-sso-oidc': 3.575.0 + '@aws-sdk/client-sso-oidc': 3.575.0(@aws-sdk/client-sts@3.575.0) '@aws-sdk/types': 3.575.0 '@smithy/property-provider': 3.1.3 '@smithy/shared-ini-file-loader': 3.1.4 @@ -37807,7 +37807,7 @@ snapshots: '@aws-sdk/token-providers@3.614.0(@aws-sdk/client-sso-oidc@3.575.0)': dependencies: - '@aws-sdk/client-sso-oidc': 3.575.0 + '@aws-sdk/client-sso-oidc': 3.575.0(@aws-sdk/client-sts@3.575.0) '@aws-sdk/types': 3.609.0 '@smithy/property-provider': 3.1.3 '@smithy/shared-ini-file-loader': 3.1.4 @@ -51338,16 +51338,16 @@ snapshots: '@rjsf/validator-ajv8@5.17.1(@rjsf/utils@5.20.0(react@18.3.1))': dependencies: '@rjsf/utils': 5.20.0(react@18.3.1) - ajv: 8.12.0 - ajv-formats: 2.1.1(ajv@8.12.0) + ajv: 8.13.0 + ajv-formats: 2.1.1(ajv@8.13.0) lodash: 4.17.21 lodash-es: 4.17.21 '@rjsf/validator-ajv8@5.17.1(@rjsf/utils@5.20.1(react@18.3.1))': dependencies: '@rjsf/utils': 5.20.1(react@18.3.1) - ajv: 8.12.0 - ajv-formats: 2.1.1(ajv@8.12.0) + ajv: 8.13.0 + ajv-formats: 2.1.1(ajv@8.13.0) lodash: 4.17.21 lodash-es: 4.17.21 @@ -53965,12 +53965,6 @@ snapshots: '@stdlib/utils-constructor-name': 0.0.8 '@stdlib/utils-global': 0.0.7 - '@stoplight/better-ajv-errors@1.0.3(ajv@8.12.0)': - dependencies: - ajv: 8.12.0 - jsonpointer: 5.0.1 - leven: 3.1.0 - '@stoplight/better-ajv-errors@1.0.3(ajv@8.13.0)': dependencies: ajv: 8.13.0 @@ -54036,7 +54030,7 @@ snapshots: '@stoplight/spectral-core@1.18.3(encoding@0.1.13)': dependencies: - '@stoplight/better-ajv-errors': 1.0.3(ajv@8.12.0) + '@stoplight/better-ajv-errors': 1.0.3(ajv@8.13.0) '@stoplight/json': 3.21.0 '@stoplight/path': 1.3.2 '@stoplight/spectral-parsers': 1.0.3 @@ -54045,9 +54039,9 @@ snapshots: '@stoplight/types': 13.6.0 '@types/es-aggregate-error': 1.0.6 '@types/json-schema': 7.0.15 - ajv: 8.12.0 - ajv-errors: 3.0.0(ajv@8.12.0) - ajv-formats: 2.1.1(ajv@8.12.0) + ajv: 8.13.0 + ajv-errors: 3.0.0(ajv@8.13.0) + ajv-formats: 2.1.1(ajv@8.13.0) es-aggregate-error: 1.0.11 jsonpath-plus: 7.1.0 lodash: 4.17.21 @@ -54149,7 +54143,7 @@ snapshots: '@stoplight/types': 13.20.0 '@stoplight/yaml': 4.2.3 '@types/node': 20.16.5 - ajv: 8.12.0 + ajv: 8.13.0 ast-types: 0.14.2 astring: 1.8.6 reserved: 0.1.2 @@ -54161,7 +54155,7 @@ snapshots: '@stoplight/spectral-rulesets@1.18.1(encoding@0.1.13)': dependencies: '@asyncapi/specs': 4.3.1 - '@stoplight/better-ajv-errors': 1.0.3(ajv@8.12.0) + '@stoplight/better-ajv-errors': 1.0.3(ajv@8.13.0) '@stoplight/json': 3.21.0 '@stoplight/spectral-core': 1.18.3(encoding@0.1.13) '@stoplight/spectral-formats': 1.6.0(encoding@0.1.13) @@ -54169,8 +54163,8 @@ snapshots: '@stoplight/spectral-runtime': 1.1.2(encoding@0.1.13) '@stoplight/types': 13.20.0 '@types/json-schema': 7.0.15 - ajv: 8.12.0 - ajv-formats: 2.1.1(ajv@8.12.0) + ajv: 8.13.0 + ajv-formats: 2.1.1(ajv@8.13.0) json-schema-traverse: 1.0.0 lodash: 4.17.21 tslib: 2.7.0 @@ -59749,10 +59743,6 @@ snapshots: optionalDependencies: ajv: 8.13.0 - ajv-errors@3.0.0(ajv@8.12.0): - dependencies: - ajv: 8.12.0 - ajv-errors@3.0.0(ajv@8.13.0): dependencies: ajv: 8.13.0