From 7656139a6882d56a5d4e7a906115352a812fc2b8 Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Wed, 19 Aug 2026 19:19:43 +0530 Subject: [PATCH 1/4] fix(api-service, worker): enhance error handling for invalid JSON bodies in HTTP requests fixes NV-8597 (#12372) --- .../test-http-endpoint.usecase.ts | 11 +- .../execute-http-request-step.usecase.spec.ts | 72 ++++++++++- .../execute-http-request-step.usecase.ts | 34 ++++- .../http-client/http-request.utils.spec.ts | 120 +++++++++++++++++- .../http-client/http-request.utils.ts | 87 +++++++++++++ 5 files changed, 310 insertions(+), 14 deletions(-) diff --git a/apps/api/src/app/workflows-v2/usecases/test-http-endpoint/test-http-endpoint.usecase.ts b/apps/api/src/app/workflows-v2/usecases/test-http-endpoint/test-http-endpoint.usecase.ts index 94e5ba9ebb8..462a4e72210 100644 --- a/apps/api/src/app/workflows-v2/usecases/test-http-endpoint/test-http-endpoint.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/test-http-endpoint/test-http-endpoint.usecase.ts @@ -1,6 +1,7 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { assertSafeOutboundUrl, + buildInvalidJsonBodyDetail, buildNovuSignatureHeader, enhanceStepsMap, GetDecryptedSecretKey, @@ -66,8 +67,6 @@ export class TestHttpEndpointUsecase { const method = (compiled.method as string) ?? 'GET'; const compiledHeaders = (compiled.headers as KeyValuePair[]) ?? []; const compiledBody = compiled.body as string | KeyValuePair[] | undefined; - const resolvedBodyInput = - typeof compiledBody === 'string' && compiledBody.trim() ? repairJsonString(compiledBody) : compiledBody; const resolvedHeaders: Record = Object.fromEntries( compiledHeaders.filter(({ key }) => key).map(({ key, value }) => [key, value]) @@ -77,13 +76,15 @@ export class TestHttpEndpointUsecase { let resolvedBody: Record | unknown[] | undefined; try { + // `repairJsonString` throws on bodies it cannot repair, so it has to stay inside this + // try/catch to return a 400 with the reason instead of an unhandled 500. + const resolvedBodyInput = + typeof compiledBody === 'string' && compiledBody.trim() ? repairJsonString(compiledBody) : compiledBody; resolvedBody = resolveHttpRequestBody(resolvedBodyInput); } catch (parseError) { - const errorMessage = parseError instanceof Error ? parseError.message : 'Failed to parse raw JSON body'; - return { statusCode: 400, - body: { error: `Invalid raw JSON body: ${errorMessage}` }, + body: buildInvalidJsonBodyDetail(parseError, compiledBody), headers: {}, durationMs: Math.round(performance.now() - startTime), resolvedRequest: { diff --git a/apps/worker/src/app/workflow/usecases/send-message/execute-http-request-step.usecase.spec.ts b/apps/worker/src/app/workflow/usecases/send-message/execute-http-request-step.usecase.spec.ts index c77fba627b5..c30873bcf68 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/execute-http-request-step.usecase.spec.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/execute-http-request-step.usecase.spec.ts @@ -1,8 +1,9 @@ +import { SECRET_MASK } from '@novu/shared'; import { expect } from 'chai'; import sinon from 'sinon'; import { ExecuteHttpRequestStep } from './execute-http-request-step.usecase'; import { SendMessageChannelCommand } from './send-message-channel.command'; -import { SendMessageStatus } from './send-message-type.usecase'; +import { SendMessageResultFailed, SendMessageStatus } from './send-message-type.usecase'; describe('ExecuteHttpRequestStep - steps namespace', () => { function buildDigestStepsMap() { @@ -78,10 +79,18 @@ describe('ExecuteHttpRequestStep - steps namespace', () => { usecase, httpClientService, executeBridgeJob, + createExecutionDetails, }; } - function buildCommand() { + function findFailureDetail(createExecutionDetails: { execute: sinon.SinonStub }) { + return createExecutionDetails.execute + .getCalls() + .map((call) => call.args[0] as { detail: string; raw?: string }) + .find((args) => args.raw?.includes('Invalid raw JSON body')); + } + + function buildCommand(env: Record = { name: 'Development', type: 'dev' }) { return SendMessageChannelCommand.create({ environmentId: 'env_1', organizationId: 'org_1', @@ -128,7 +137,7 @@ describe('ExecuteHttpRequestStep - steps namespace', () => { events: undefined, total_count: undefined, }, - env: { name: 'Development', type: 'dev' }, + env, } as never, bridgeData: null, environment: { _id: 'env_1' } as never, @@ -189,4 +198,61 @@ describe('ExecuteHttpRequestStep - steps namespace', () => { expect(requestArgs.headers['X-Digest-Summary']).to.equal('Ada, Grace'); expect(requestArgs.body).to.deep.equal({ summary: '2 notifications' }); }); + + it('records an execution detail when the compiled body cannot be repaired into valid JSON', async () => { + const { usecase, httpClientService, createExecutionDetails } = buildUsecase({ + url: 'https://example.com/webhook', + method: 'POST', + body: '{"order":{"lines":[{"item":{"sku" }}]}}', + }); + + const result = (await usecase.execute(buildCommand())) as SendMessageResultFailed; + + expect(result.status).to.equal(SendMessageStatus.FAILED); + expect(result.shouldHalt).to.equal(true); + expect(httpClientService.request.called).to.equal(false); + + const failureDetail = findFailureDetail(createExecutionDetails); + expect(failureDetail, 'expected a failed execution detail for the unrepairable body').to.not.equal(undefined); + + const raw = JSON.parse(failureDetail?.raw ?? '{}'); + expect(raw.error).to.contain('Colon expected'); + expect(raw.bodyExcerpt).to.contain('"sku"'); + expect(raw.hint).to.be.a('string'); + }); + + it('does not halt the chain for an unrepairable body when continueOnFailure is enabled', async () => { + const { usecase, createExecutionDetails } = buildUsecase({ + url: 'https://example.com/webhook', + method: 'POST', + continueOnFailure: true, + body: '{"order":{"lines":[{"item":{"sku" }}]}}', + }); + + const result = (await usecase.execute(buildCommand())) as SendMessageResultFailed; + + expect(result.status).to.equal(SendMessageStatus.FAILED); + expect(result.shouldHalt).to.equal(false); + expect(findFailureDetail(createExecutionDetails)).to.not.equal(undefined); + }); + + it('masks rendered environment variable secrets out of the persisted excerpt', async () => { + const secret = 'sk_live_51NQpZmKq7xTvR3wY'; + const { usecase, createExecutionDetails } = buildUsecase({ + url: 'https://example.com/webhook', + method: 'POST', + body: '{"token":"{{env.PARTNER_API_KEY}}","tier":"{{env.type}}","order":{"sku" }}', + }); + + const result = await usecase.execute(buildCommand({ name: 'Production', type: 'prod', PARTNER_API_KEY: secret })); + + expect(result.status).to.equal(SendMessageStatus.FAILED); + + const raw = JSON.parse(findFailureDetail(createExecutionDetails)?.raw ?? '{}'); + expect(raw.bodyExcerpt, 'the excerpt must not carry the decrypted env secret').to.not.contain(secret); + expect(raw.bodyExcerpt).to.contain(SECRET_MASK); + // System env values are not secrets, and masking them would gut the excerpt. + expect(raw.bodyExcerpt).to.contain('"tier":"prod"'); + expect(raw.bodyExcerpt).to.contain('"order":{"sku" }'); + }); }); diff --git a/apps/worker/src/app/workflow/usecases/send-message/execute-http-request-step.usecase.ts b/apps/worker/src/app/workflow/usecases/send-message/execute-http-request-step.usecase.ts index c44a04b5021..39762758b88 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/execute-http-request-step.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/execute-http-request-step.usecase.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { assertSafeOutboundUrl, + buildInvalidJsonBodyDetail, buildNovuSignatureHeader, CreateExecutionDetails, CreateExecutionDetailsCommand, @@ -25,6 +26,7 @@ import { ControlValuesLevelEnum, DeliveryLifecycleDetail, DeliveryLifecycleStatusEnum, + EnvironmentSystemVariables, ExecutionDetailsSourceEnum, ExecutionDetailsStatusEnum, isOutboundSsrfProtectionEnabled, @@ -137,8 +139,6 @@ export class ExecuteHttpRequestStep extends SendMessageType { const method = (compiled.method as string) ?? 'POST'; const rawHeaders = (compiled.headers as Array<{ key: string; value: string }> | undefined) ?? []; const compiledBody = compiled.body as string | Array<{ key: string; value: string }> | undefined; - const rawBody = - typeof compiledBody === 'string' && compiledBody.trim() ? repairJsonString(compiledBody) : compiledBody; const timeout = (compiled.timeout as number | undefined) ?? 5000; if (!url) { @@ -191,10 +191,12 @@ export class ExecuteHttpRequestStep extends SendMessageType { let bodyObject: Record | unknown[] | undefined; try { + // `repairJsonString` throws on bodies it cannot repair, so it has to stay inside this + // try/catch to surface the failure as an execution detail instead of an unhandled job error. + const rawBody = + typeof compiledBody === 'string' && compiledBody.trim() ? repairJsonString(compiledBody) : compiledBody; bodyObject = resolveHttpRequestBody(rawBody); } catch (parseError) { - const errorMessage = parseError instanceof Error ? parseError.message : 'Failed to parse raw JSON body'; - await this.createExecutionDetails.execute( CreateExecutionDetailsCommand.create({ ...CreateExecutionDetailsCommand.getDetailsFromJob(command.job), @@ -203,7 +205,9 @@ export class ExecuteHttpRequestStep extends SendMessageType { status: ExecutionDetailsStatusEnum.FAILED, isTest: false, isRetry: false, - raw: JSON.stringify({ error: `Invalid raw JSON body: ${errorMessage}` }), + raw: JSON.stringify( + buildInvalidJsonBodyDetail(parseError, compiledBody, collectSecretEnvValues(command.compileContext?.env)) + ), }) ); @@ -426,6 +430,26 @@ export class ExecuteHttpRequestStep extends SendMessageType { } } +/** + * Compile-safe: adding a field to EnvironmentSystemVariables will cause a TS error here. + */ +const SYSTEM_ENV_KEYS: Record = { name: true, type: true }; + +/** + * `env` merges decrypted environment variables, which can hold API keys and tokens, with the + * environment's system variables. Only the user-defined values are treated as secrets: the system + * values are not sensitive, and masking strings as common as `prod` would gut the excerpt. + */ +function collectSecretEnvValues(env: unknown): string[] { + if (!env || typeof env !== 'object') { + return []; + } + + return Object.entries(env as Record) + .filter(([key, value]) => !(key in SYSTEM_ENV_KEYS) && typeof value === 'string' && value.length > 0) + .map(([, value]) => value as string); +} + function getSkipRules(controlValues: Record): RulesLogic | undefined { const skipRules = controlValues.skip as RulesLogic | undefined; diff --git a/libs/application-generic/src/services/http-client/http-request.utils.spec.ts b/libs/application-generic/src/services/http-client/http-request.utils.spec.ts index 9fe996224d5..a4772756ef1 100644 --- a/libs/application-generic/src/services/http-client/http-request.utils.spec.ts +++ b/libs/application-generic/src/services/http-client/http-request.utils.spec.ts @@ -1,5 +1,12 @@ +import { SECRET_MASK } from '@novu/shared'; import { expect } from 'chai'; -import { parseRawBody, resolveHttpRequestBody, toBodyRecord, toHeadersRecord } from './http-request.utils'; +import { + buildInvalidJsonBodyDetail, + parseRawBody, + resolveHttpRequestBody, + toBodyRecord, + toHeadersRecord, +} from './http-request.utils'; describe('http-request.utils', () => { describe('toBodyRecord', () => { @@ -88,4 +95,115 @@ describe('http-request.utils', () => { expect(() => resolveHttpRequestBody('not json')).to.throw(); }); }); + + describe('buildInvalidJsonBodyDetail', () => { + const buildLongBody = (broken: string) => `{"padding":"${'x'.repeat(500)}","order":${broken}}`; + + it('should excerpt the body around a position carried on the error', () => { + const body = buildLongBody('{"sku" }'); + const position = body.indexOf('{"sku" }') + 7; + const detail = buildInvalidJsonBodyDetail(Object.assign(new Error('Colon expected'), { position }), body); + + expect(detail.error).to.equal('Invalid raw JSON body: Colon expected'); + expect(detail.bodyExcerpt).to.contain('"order":{"sku" }'); + expect(detail.bodyExcerpt).to.contain('...'); + expect(detail.bodyExcerpt).to.not.contain('x'.repeat(200)); + }); + + it('should read the position out of a JSON.parse message when none is attached', () => { + const body = buildLongBody('{"sku" }'); + let thrown: unknown; + try { + JSON.parse(body); + } catch (error) { + thrown = error; + } + + const detail = buildInvalidJsonBodyDetail(thrown, body); + + expect(detail.bodyExcerpt).to.contain('"order":{"sku" }'); + }); + + it('should omit the excerpt when the position cannot be determined', () => { + const detail = buildInvalidJsonBodyDetail(new Error('Raw body must be a JSON object or array'), '"hello"'); + + expect(detail.error).to.equal('Invalid raw JSON body: Raw body must be a JSON object or array'); + expect(detail.bodyExcerpt).to.equal(undefined); + expect(detail.hint).to.be.a('string'); + }); + + it('should omit the excerpt for key-value pair bodies', () => { + const detail = buildInvalidJsonBodyDetail(Object.assign(new Error('Colon expected'), { position: 3 }), [ + { key: 'name', value: 'test' }, + ]); + + expect(detail.bodyExcerpt).to.equal(undefined); + }); + + it('should fall back to a generic message for non-Error throwables', () => { + const detail = buildInvalidJsonBodyDetail('boom', '{}'); + + expect(detail.error).to.equal('Invalid raw JSON body: Failed to parse raw JSON body'); + }); + + it('should mask secret values that land inside the excerpt', () => { + const secret = 'sk_live_51NQpZmKq7xTvR3wY'; + const body = `{"token":"${secret}","order":{"sku" }}`; + const position = body.indexOf('{"sku" }') + 7; + const detail = buildInvalidJsonBodyDetail(Object.assign(new Error('Colon expected'), { position }), body, [ + secret, + ]); + + expect(detail.bodyExcerpt).to.not.contain(secret); + expect(detail.bodyExcerpt).to.contain(SECRET_MASK); + expect(detail.bodyExcerpt).to.contain('"order":{"sku" }'); + }); + + it('should mask a secret rendered in its JSON-escaped form', () => { + const secret = 'pa$$"word\nline'; + const escaped = JSON.stringify(secret).slice(1, -1); + const body = `{"token":"${escaped}","order":{"sku" }}`; + const position = body.indexOf('{"sku" }') + 7; + const detail = buildInvalidJsonBodyDetail(Object.assign(new Error('Colon expected'), { position }), body, [ + secret, + ]); + + expect(detail.bodyExcerpt).to.not.contain(escaped); + expect(detail.bodyExcerpt).to.contain(SECRET_MASK); + }); + + it('should not leak a partial secret straddling the excerpt boundary', () => { + const secret = `sk_live_${'a'.repeat(80)}_tail`; + // Places the secret so that only its tail would fall inside an unmasked window. + const body = `{"token":"${secret}","order":{"sku" }}`; + const position = body.indexOf('{"sku" }') + 7; + const detail = buildInvalidJsonBodyDetail(Object.assign(new Error('Colon expected'), { position }), body, [ + secret, + ]); + + expect(detail.bodyExcerpt).to.not.contain('aaaa'); + expect(detail.bodyExcerpt).to.not.contain('_tail'); + expect(detail.bodyExcerpt).to.contain('"order":{"sku" }'); + }); + + it('should keep the excerpt centered on the failure after masking', () => { + const secret = 'sk_live_51NQpZmKq7xTvR3wY'; + const body = `{"token":"${secret}","padding":"${'y'.repeat(300)}","order":{"sku" }}`; + const position = body.indexOf('{"sku" }') + 7; + const detail = buildInvalidJsonBodyDetail(Object.assign(new Error('Colon expected'), { position }), body, [ + secret, + ]); + + expect(detail.bodyExcerpt).to.contain('"order":{"sku" }'); + }); + + it('should ignore empty secret values', () => { + const body = '{"order":{"sku" }}'; + const detail = buildInvalidJsonBodyDetail(Object.assign(new Error('Colon expected'), { position: 16 }), body, [ + '', + ]); + + expect(detail.bodyExcerpt).to.equal(body); + }); + }); }); diff --git a/libs/application-generic/src/services/http-client/http-request.utils.ts b/libs/application-generic/src/services/http-client/http-request.utils.ts index 82a284df99c..daaf2174408 100644 --- a/libs/application-generic/src/services/http-client/http-request.utils.ts +++ b/libs/application-generic/src/services/http-client/http-request.utils.ts @@ -1,3 +1,5 @@ +import { SECRET_MASK } from '@novu/shared'; + export type KeyValuePair = { key: string; value: string }; export type HttpRequestBodyControl = string | KeyValuePair[] | undefined; @@ -45,3 +47,88 @@ export function shouldIncludeBody(body: Record | unknown[] | un return !!body && !methodsWithoutBody.includes(method); } + +export interface InvalidJsonBodyDetail { + error: string; + hint: string; + bodyExcerpt?: string; +} + +const BODY_EXCERPT_RADIUS = 60; + +const INVALID_JSON_BODY_HINT = + 'The body is parsed as JSON after Liquid variables are rendered. A variable that resolves to an unescaped quote, a line break, or a raw object can break the surrounding JSON.'; + +/** + * `jsonrepair` exposes the offset as `position`; `JSON.parse` only mentions it in the message. + */ +function extractFailurePosition(error: unknown): number | undefined { + const { position } = (error ?? {}) as { position?: unknown }; + + if (typeof position === 'number' && Number.isFinite(position)) { + return position; + } + + const match = error instanceof Error ? /position (\d+)/.exec(error.message) : null; + + return match ? Number(match[1]) : undefined; +} + +/** + * Replaces every occurrence of a secret, both raw and in the JSON-escaped form it takes once + * rendered into a JSON body. + */ +function maskSecrets(text: string, secretValues: readonly string[]): string { + return secretValues.filter(Boolean).reduce((masked, secret) => { + const escaped = JSON.stringify(secret).slice(1, -1); + + return masked.split(secret).join(SECRET_MASK).split(escaped).join(SECRET_MASK); + }, text); +} + +function buildBodyExcerpt( + body: HttpRequestBodyControl, + position: number | undefined, + secretValues: readonly string[] +): string | undefined { + if (typeof body !== 'string' || position === undefined) { + return undefined; + } + + /** + * Mask the whole body before slicing: masking only the excerpt would leak a partial secret + * whenever one straddles the window boundary. Masking the prefix separately re-derives the + * reported position within the masked body so the excerpt stays centered on the failure. + */ + const maskedBody = maskSecrets(body, secretValues); + const maskedPosition = maskSecrets(body.slice(0, position), secretValues).length; + + const start = Math.max(0, maskedPosition - BODY_EXCERPT_RADIUS); + const end = Math.min(maskedBody.length, maskedPosition + BODY_EXCERPT_RADIUS); + + return `${start > 0 ? '...' : ''}${maskedBody.slice(start, end)}${end < maskedBody.length ? '...' : ''}`; +} + +/** + * Turns a JSON parse/repair failure into something a user can act on. The parsers only report a + * character offset into the rendered body, which nobody can locate by hand, so resolve it against + * the body and show the surrounding text instead. + * + * `secretValues` are masked out of the excerpt. Callers that persist the result must pass every + * decrypted environment variable value, since execution details are readable by low-privilege + * roles through the activity feed. + */ +export function buildInvalidJsonBodyDetail( + error: unknown, + body: HttpRequestBodyControl, + secretValues: readonly string[] = [] +): InvalidJsonBodyDetail { + const message = error instanceof Error ? error.message : 'Failed to parse raw JSON body'; + const bodyExcerpt = buildBodyExcerpt(body, extractFailurePosition(error), secretValues); + + return { + error: `Invalid raw JSON body: ${message}`, + hint: INVALID_JSON_BODY_HINT, + ...(bodyExcerpt ? { bodyExcerpt } : {}), + }; +} From ebee2036227438a347e6d3865ca0524236c10bcc Mon Sep 17 00:00:00 2001 From: Pawan Jain Date: Wed, 19 Aug 2026 20:29:17 +0530 Subject: [PATCH 2/4] feat(docs): add Tool channel information in framework fixes DOC-434 (#12394) --- docs/docs.json | 6 +- docs/framework/custom.mdx | 2 + docs/framework/introduction.mdx | 2 +- docs/framework/tool-channel.mdx | 295 ++++++++++++++++++ docs/framework/typescript/steps/tool.mdx | 26 ++ docs/framework/typescript/workflow.mdx | 1 + .../add-and-configure-steps/code-steps.mdx | 14 + .../step/discovery/step-discovery.spec.ts | 4 +- .../commands/step/discovery/step-discovery.ts | 1 + packages/novu/src/commands/step/publish.ts | 3 +- .../__snapshots__/step-file.spec.ts.snap | 51 +++ .../novu/src/commands/step/templates/index.ts | 1 + .../commands/step/templates/step-file.spec.ts | 17 + .../src/commands/step/templates/step-file.ts | 26 ++ 14 files changed, 443 insertions(+), 6 deletions(-) create mode 100644 docs/framework/tool-channel.mdx create mode 100644 docs/framework/typescript/steps/tool.mdx diff --git a/docs/docs.json b/docs/docs.json index fad83917ee2..08dad79a68b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1002,7 +1002,8 @@ "framework/in-app-channel", "framework/push-channel", "framework/sms-channel", - "framework/chat-channel" + "framework/chat-channel", + "framework/tool-channel" ], "icon": "radio" }, @@ -1041,7 +1042,8 @@ "framework/typescript/steps/inApp", "framework/typescript/steps", "framework/typescript/steps/push", - "framework/typescript/steps/sms" + "framework/typescript/steps/sms", + "framework/typescript/steps/tool" ] }, "framework/schema/zod", diff --git a/docs/framework/custom.mdx b/docs/framework/custom.mdx index bbe2b0a78ec..4a136531ee0 100644 --- a/docs/framework/custom.mdx +++ b/docs/framework/custom.mdx @@ -12,6 +12,8 @@ A custom steps allows to execute any custom logic and persist in the durable exe - Execute a custom logic to transform data - Custom provider implementation +To page on-call or POST to a webhook through a Novu integration, use the [Tool channel](/framework/tool-channel) (`step.tool`) instead of implementing that delivery in `step.custom`. + ## Custom Step Interface ```tsx diff --git a/docs/framework/introduction.mdx b/docs/framework/introduction.mdx index 31db1de888f..45ba8fad9a8 100644 --- a/docs/framework/introduction.mdx +++ b/docs/framework/introduction.mdx @@ -59,7 +59,7 @@ Workflow identifiers should be unique to your application and should be descript Channel Steps are the delivery methods of the notification. In our example, we have an email Channel Step that will send an email with the subject `Welcome to Novu` and the body `Hello, welcome to Novu!`. Novu's durable workflow execution engine will select the relevant delivery provider configured for this channel and send the notification with the specified content. -Novu supports a variety of common notification channels out-of-the-box, including **email**, **SMS**, **push**, **inbox**, and **chat**. +Novu supports a variety of common notification channels out-of-the-box, including **email**, **SMS**, **push**, **inbox**, **chat**, and **tool**. To read more about the full list of parameters, check out the [full SDK reference](/framework/typescript/overview). diff --git a/docs/framework/tool-channel.mdx b/docs/framework/tool-channel.mdx new file mode 100644 index 00000000000..3dcb705086d --- /dev/null +++ b/docs/framework/tool-channel.mdx @@ -0,0 +1,295 @@ +--- +title: 'Tool' +description: "Use the Tool channel in Novu Framework to page on-call, open incidents, or POST JSON to a webhook from a workflow step." +--- + +The Tool channel delivers a payload from a workflow to an operational system. Use it to page on-call, open an incident, or POST JSON to an endpoint you control. + +It is a **channel step**, like email or SMS. Novu sends through a Tool integration in the Integration Store. It is not a replacement for [`step.custom`](/framework/custom), which runs arbitrary code in your bridge and returns structured results to later steps. + + + Connect a Tool provider before triggering. PagerDuty, Opsgenie, and Grafana route per subscriber via [channel endpoints](/api-reference/channel-endpoints/create-a-channel-endpoint). Tool webhook can use a shared URL (static) or per-subscriber URLs (dynamic). + + +## When to use `step.tool` + +| Use `step.tool` when | Use `step.custom` when | +| --- | --- | +| You want Novu to deliver to PagerDuty, Opsgenie, Grafana, or a webhook | You need to fetch or transform data in your bridge | +| Delivery should use a configured integration and Activity feed | The result must be reused in later steps | +| You need provider-specific fields (severity, tags, extra JSON keys) | You are calling an API that has no Tool provider | + +A common pattern is to fetch in `step.custom`, then page with `step.tool` using that result. + +## Providers + +| Provider | What `body` becomes | Setup | +| --- | --- | --- | +| [PagerDuty](/platform/integrations/tool/pagerduty) | Incident `payload.summary` | Per-subscriber Events API v2 routing key | +| [Opsgenie](/platform/integrations/tool/opsgenie) | Alert `message` (truncated at 130 characters) | Per-subscriber API key | +| [Grafana](/platform/integrations/tool/grafana) | Alert group `title` (truncated at 1024 characters) | Per-subscriber webhook URL | +| [Tool webhook](/platform/integrations/tool/webhook) | Request JSON `content` | Static URL on the integration, or dynamic subscriber endpoints | + +If a subscriber has no channel endpoint for an endpoint-routed provider (or no dynamic webhook URLs), Novu marks the Tool step **skipped** for that subscriber. Other subscribers on the same trigger are unaffected. + +## Define a tool step + +The resolver must return a `body` string. That string is the default content sent to the provider. + +```tsx +await step.tool('page-oncall', async () => { + return { + body: 'Payment failed for order ORD-12345', + }; +}); +``` + +### Workflow with payload + +```tsx +import { workflow } from '@novu/framework'; +import { z } from 'zod'; + +export const orderFailed = workflow( + 'order-failed', + async ({ step, payload, subscriber }) => { + await step.tool('page-oncall', async () => ({ + body: `Payment failed for order ${payload.orderNumber} (${subscriber.subscriberId}): ${payload.reason}`, + })); + }, + { + payloadSchema: z.object({ + orderNumber: z.string(), + reason: z.string(), + }), + } +); +``` + +Trigger the workflow as usual. Delivery uses the subscriber's Tool endpoints for that environment. + +```tsx +await orderFailed.trigger({ + to: 'user-123', + payload: { + orderNumber: 'ORD-12345', + reason: 'payment_declined', + }, +}); +``` + +## Provider overrides + +Use the `providers` option to pass fields the shared `body` schema does not cover. Keys are Tool provider IDs: `pagerduty`, `opsgenie`, `grafana`, and `tool-webhook`. + +Only the override for the integration that actually sends is applied. You can define several; unused ones are ignored. + + + +```tsx +await step.tool( + 'page-oncall', + async () => ({ + body: 'Payment failed for order ORD-12345', + }), + { + providers: { + pagerduty: async ({ outputs }) => ({ + severity: 'error', + source: 'checkout', + summary: outputs.body, + custom_details: { + runbook: 'https://runbooks.example.com/payments', + }, + }), + }, + } +); +``` + +PagerDuty defaults `severity` to `critical` and `source` to `novu` when you omit those fields. See [incident payload defaults](/platform/integrations/tool/pagerduty#incident-payload-defaults-and-overrides). + + +```tsx +await step.tool( + 'page-oncall', + async () => ({ + body: 'Payment failed for order ORD-12345', + }), + { + providers: { + opsgenie: async ({ outputs }) => ({ + message: outputs.body, + description: 'Card declined after 3 retries. Customer is blocked at checkout.', + priority: 'P1', + tags: ['payments', 'checkout'], + source: 'novu', + }), + }, + } +); +``` + +Opsgenie truncates `message` at 130 characters. Put detail in `description` (up to 15,000 characters). See [alert payload defaults](/platform/integrations/tool/opsgenie#alert-payload-defaults-and-overrides). + + +```tsx +await step.tool( + 'page-oncall', + async () => ({ + body: 'Payment failed for order ORD-12345', + }), + { + providers: { + grafana: async ({ outputs }) => ({ + title: outputs.body, + message: 'Card declined after 3 retries.', + state: 'alerting', + link_to_upstream_details: 'https://app.example.com/orders/ORD-12345', + }), + }, + } +); +``` + +Send `state: 'ok'` with the same `alert_uid` to auto-resolve. See [alert payload defaults](/platform/integrations/tool/grafana#alert-payload-defaults-and-overrides). + + +```tsx +await step.tool( + 'notify-ops', + async () => ({ + body: 'Payment failed for order ORD-12345', + }), + { + providers: { + 'tool-webhook': async ({ outputs }) => ({ + alert_type: 'incident', + title: outputs.body, + }), + }, + } +); +``` + +The rendered `body` is always sent as `content` on the JSON request. Extra keys from the override merge into that object. See [request shape](/platform/integrations/tool/webhook#request-shape). + + + +You can also use `_passthrough` to merge extra `body`, `headers`, or `query` into the underlying provider request. See [provider overrides](/framework/typescript/steps#providers-overrides-object). + +## Step controls + +Expose copy that non-developers can edit in the dashboard without changing code. + +```tsx +import { z } from 'zod'; + +await step.tool( + 'page-oncall', + async (controls) => ({ + body: controls.body, + }), + { + controlSchema: z.object({ + body: z.string().default('An incident requires attention.'), + }), + providers: { + pagerduty: async ({ controls, outputs }) => ({ + summary: outputs.body, + severity: 'error', + source: 'checkout', + }), + }, + } +); +``` + +After you sync the workflow, the dashboard renders a **body** field for this step. Payload data still comes from `novu.trigger`. Learn more about [controls](/framework/controls). + +## Skip the step + +Skip delivery from previous-step results, payload flags, or subscriber data. + +```tsx +workflow('order-failed', async ({ step, payload }) => { + const order = await step.custom( + 'load-order', + async () => { + const record = await db.orders.find(payload.orderNumber); + + return { + orderNumber: record.id, + alreadyPaged: record.pagerDutyIncidentId != null, + }; + }, + { + outputSchema: { + type: 'object', + properties: { + orderNumber: { type: 'string' }, + alreadyPaged: { type: 'boolean' }, + }, + required: ['orderNumber', 'alreadyPaged'], + }, + } + ); + + await step.tool( + 'page-oncall', + async () => ({ + body: `Payment failed for order ${order.orderNumber}`, + }), + { + skip: () => order.alreadyPaged || payload.severity === 'low', + } + ); +}); +``` + +`skip` runs at send time, not during dashboard preview. See [skip](/framework/skip). + +## Channel preferences + +Disable Tool for a workflow (or leave it subscriber-controlled) with `preferences.channels.tool`: + +```tsx +workflow( + 'order-failed', + async ({ step, payload }) => { + await step.tool('page-oncall', async () => ({ + body: `Payment failed for order ${payload.orderNumber}`, + })); + }, + { + preferences: { + channels: { + tool: { enabled: true }, + }, + }, + } +); +``` + +## Output + +The resolver returns `{ body: string }`. The step does not return a result, so you cannot branch later steps on whether the provider accepted the request. + +See the [Tool step reference](/framework/typescript/steps/tool). + +## Related + + + + Output schema and SDK types. + + + Static vs dynamic routing, request body merge, and HMAC signatures. + + + Per-subscriber routing keys and Events API v2 incident fields. + + + Fetch data in your bridge, then pass it into `step.tool`. + + diff --git a/docs/framework/typescript/steps/tool.mdx b/docs/framework/typescript/steps/tool.mdx new file mode 100644 index 00000000000..e5835ea77eb --- /dev/null +++ b/docs/framework/typescript/steps/tool.mdx @@ -0,0 +1,26 @@ +--- +title: 'Tool' +description: "Use the tool step in Novu Framework to deliver a payload to PagerDuty, Opsgenie, Grafana, or a Tool webhook as part of a workflow." +--- + +The `tool` step delivers a `body` string through a Tool integration. For when to use it versus `step.custom`, provider mapping, overrides, skip, and controls, see the [Tool channel](/framework/tool-channel) guide. + +## Example Usage + +```tsx +await step.tool('page-oncall', async () => { + return { + body: 'Payment failed for order ORD-12345', + }; +}); +``` + +## Tool Step Output + +| Property | Type | Required | Description | +| -------- | ------ | -------- | ------------------------------------------------ | +| body | string | Yes | The payload sent to the Tool provider as content | + +## Tool Step Result + +The `tool` step does not return any result object. diff --git a/docs/framework/typescript/workflow.mdx b/docs/framework/typescript/workflow.mdx index ad7c580e1ea..b4039f26cfc 100644 --- a/docs/framework/typescript/workflow.mdx +++ b/docs/framework/typescript/workflow.mdx @@ -104,6 +104,7 @@ workflow(workflowId, handler, options); - `sms`: `{ enabled: boolean }` - SMS channel preferences - `chat`: `{ enabled: boolean }` - Chat channel preferences - `push`: `{ enabled: boolean }` - Push channel preferences + - `tool`: `{ enabled: boolean }` - Tool channel preferences ## Workflow Context diff --git a/docs/platform/workflow/add-and-configure-steps/code-steps.mdx b/docs/platform/workflow/add-and-configure-steps/code-steps.mdx index 6f6c2df043b..e046a92ee47 100644 --- a/docs/platform/workflow/add-and-configure-steps/code-steps.mdx +++ b/docs/platform/workflow/add-and-configure-steps/code-steps.mdx @@ -16,6 +16,20 @@ You can create both UI-managed steps and code-managed steps within the same work | Push | `subject`, `body` | | Chat | `body` | | In-App | `subject`, `body` (plus optional `avatar`, `primaryAction`, `secondaryAction`, `data`, `redirect`) | +| Tool | `body` | + +Tool steps use the same handler shape as other channels. Return a `body` string; Novu delivers it through a [Tool integration](/platform/integrations/tool/webhook). See the [Tool channel](/framework/tool-channel) guide for provider overrides and skip. + +```typescript +import { step } from '@novu/framework/step-resolver'; + +export default step.tool( + 'page-oncall', + async (controls, { payload }) => ({ + body: `Payment failed for order ${payload.orderNumber}: ${payload.reason}`, + }) +); +``` ## Quick Start diff --git a/packages/novu/src/commands/step/discovery/step-discovery.spec.ts b/packages/novu/src/commands/step/discovery/step-discovery.spec.ts index 78ea82d9f37..f228708ec33 100644 --- a/packages/novu/src/commands/step/discovery/step-discovery.spec.ts +++ b/packages/novu/src/commands/step/discovery/step-discovery.spec.ts @@ -90,7 +90,7 @@ describe('step-discovery', () => { }); it('accepts all supported channel step types', async () => { - for (const type of ['email', 'sms', 'chat', 'push']) { + for (const type of ['email', 'sms', 'chat', 'push', 'tool']) { writeStepFile( `onboarding/${type}-step.step.ts`, createStepFileContent({ stepId: `${type}-step`, type, useJsx: false }) @@ -105,7 +105,7 @@ describe('step-discovery', () => { expect(result.valid).toBe(true); expect(result.errors).toHaveLength(0); - expect(result.steps).toHaveLength(5); + expect(result.steps).toHaveLength(6); }); it('detects invalid step type', async () => { diff --git a/packages/novu/src/commands/step/discovery/step-discovery.ts b/packages/novu/src/commands/step/discovery/step-discovery.ts index 655f7b25317..13550dcfb85 100644 --- a/packages/novu/src/commands/step/discovery/step-discovery.ts +++ b/packages/novu/src/commands/step/discovery/step-discovery.ts @@ -27,6 +27,7 @@ const METHOD_NAME_TO_TYPE: Record = { chat: 'chat', push: 'push', inApp: 'in_app', + tool: 'tool', delay: 'delay', digest: 'digest', throttle: 'throttle', diff --git a/packages/novu/src/commands/step/publish.ts b/packages/novu/src/commands/step/publish.ts index 0ed0706fb97..9c97acc993a 100644 --- a/packages/novu/src/commands/step/publish.ts +++ b/packages/novu/src/commands/step/publish.ts @@ -45,7 +45,7 @@ const RELEASE_ARTIFACT_BASENAME = 'step-resolver-release'; type ScaffoldResult = { mode: 'react-email'; templatePath: string } | { mode: 'placeholder'; stepType: string }; -const KNOWN_STEP_TYPES = new Set(['email', 'sms', 'push', 'chat', 'in_app', 'delay', 'digest', 'throttle']); +const KNOWN_STEP_TYPES = new Set(['email', 'sms', 'push', 'chat', 'in_app', 'tool', 'delay', 'digest', 'throttle']); export async function stepPublish(options: PublishOptions): Promise { try { @@ -218,6 +218,7 @@ async function promptForStepType(rootDir: string): Promise should match snapshot with zod 1`] = ` +"import { step } from '@novu/framework/step-resolver'; +import { z } from 'zod'; + +export default step.tool( + 'page-oncall', + async (controls) => ({ + body: controls.body, + }), + { + controlSchema: z.object({ + body: z.string().default('An incident requires attention.'), + }), + // providers: { + // 'tool-webhook': async ({ outputs }) => ({ + // alert_type: 'incident', + // title: outputs.body, + // }), + // }, + } +); +" +`; + +exports[`generateToolStepFile > should match snapshot without zod 1`] = ` +"import { step } from '@novu/framework/step-resolver'; + +export default step.tool( + 'page-oncall', + async (controls) => ({ + body: controls.body, + }), + { + controlSchema: { + type: 'object', + properties: { + body: { type: 'string', default: 'An incident requires attention.' }, + }, + additionalProperties: false, + } as const, + // providers: { + // 'tool-webhook': async ({ outputs }) => ({ + // alert_type: 'incident', + // title: outputs.body, + // }), + // }, + } +); +" +`; diff --git a/packages/novu/src/commands/step/templates/index.ts b/packages/novu/src/commands/step/templates/index.ts index 2bffd3f4013..e17e78e6484 100644 --- a/packages/novu/src/commands/step/templates/index.ts +++ b/packages/novu/src/commands/step/templates/index.ts @@ -6,4 +6,5 @@ export { generateReactEmailStepFile, generateSmsStepFile, generateStepFileForType, + generateToolStepFile, } from './step-file'; diff --git a/packages/novu/src/commands/step/templates/step-file.spec.ts b/packages/novu/src/commands/step/templates/step-file.spec.ts index 6fdaa986207..dc2ad49a641 100644 --- a/packages/novu/src/commands/step/templates/step-file.spec.ts +++ b/packages/novu/src/commands/step/templates/step-file.spec.ts @@ -7,6 +7,7 @@ import { generateReactEmailStepFile, generateSmsStepFile, generateStepFileForType, + generateToolStepFile, } from './step-file'; describe('generateReactEmailStepFile', () => { @@ -115,6 +116,16 @@ describe('generateChatStepFile', () => { }); }); +describe('generateToolStepFile', () => { + it('should match snapshot with zod', () => { + expect(generateToolStepFile('page-oncall', true)).toMatchSnapshot(); + }); + + it('should match snapshot without zod', () => { + expect(generateToolStepFile('page-oncall', false)).toMatchSnapshot(); + }); +}); + describe('generateInAppStepFile', () => { it('should match snapshot with zod', () => { expect(generateInAppStepFile('in-app-notify', true)).toMatchSnapshot(); @@ -145,4 +156,10 @@ describe('generateStepFileForType', () => { expect(result).not.toContain("from 'zod'"); expect(result).toContain('as const'); }); + + it('scaffolds a tool channel step', () => { + const result = generateStepFileForType('page-oncall', 'tool', false); + expect(result).toContain('step.tool('); + expect(result).toContain("'page-oncall'"); + }); }); diff --git a/packages/novu/src/commands/step/templates/step-file.ts b/packages/novu/src/commands/step/templates/step-file.ts index 477f6ed9ed3..5c9adb5cf64 100644 --- a/packages/novu/src/commands/step/templates/step-file.ts +++ b/packages/novu/src/commands/step/templates/step-file.ts @@ -166,6 +166,31 @@ export default step.chat( `; } +const toolFields: ControlFields = { + body: { default: 'An incident requires attention.' }, +}; + +export function generateToolStepFile(stepId: string, useZod: boolean): string { + return `${stepImports(useZod)} + +export default step.tool( + '${escapeString(stepId)}', + async (controls) => ({ + body: controls.body, + }), + { + controlSchema: ${controlSchema(toolFields, useZod)}, + // providers: { + // 'tool-webhook': async ({ outputs }) => ({ + // alert_type: 'incident', + // title: outputs.body, + // }), + // }, + } +); +`; +} + const inAppFields: ControlFields = { subject: { default: 'New activity' }, body: { default: 'You have a new notification.' }, @@ -283,6 +308,7 @@ const STEP_GENERATORS: Record strin push: generatePushStepFile, chat: generateChatStepFile, in_app: generateInAppStepFile, + tool: generateToolStepFile, delay: generateDelayStepFile, digest: generateDigestStepFile, throttle: generateThrottleStepFile, From 22c4b95fe109d4eb2d411ddbb75a42e4550b1a93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Tymczuk?= Date: Wed, 19 Aug 2026 17:01:17 +0200 Subject: [PATCH 3/4] chore(dashboard): chat editor full height fix when provider overrides ff is off (#12395) --- .../workflow-editor/steps/chat/chat-editor.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/dashboard/src/components/workflow-editor/steps/chat/chat-editor.tsx b/apps/dashboard/src/components/workflow-editor/steps/chat/chat-editor.tsx index 614ee2b11ae..72545ceeb08 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/chat/chat-editor.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/chat/chat-editor.tsx @@ -85,14 +85,16 @@ export const ChatEditor = (props: ChatEditorProps) => { if (defaultContentActions) { return ( - -
-
{defaultContentActions}
-
- {defaultContent} +
+ +
+
{defaultContentActions}
+
+ {defaultContent} +
-
- + +
); } From efdf4b287952a467f983b35a5003a9e04efe44d6 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Wed, 19 Aug 2026 18:31:48 +0300 Subject: [PATCH 4/4] fix(api-service): mask secret env vars in preview and delivery fixes NV-8614 (#12385) Co-authored-by: Cursor Agent --- .../src/app/events/e2e/trigger-event.e2e.ts | 60 +++++++++++++++++++ .../workflows-v2/e2e/generate-preview.e2e.ts | 38 ++++++++++++ .../send-message/send-message.usecase.ts | 14 ++++- .../encrypt-environment-variable.spec.ts | 52 ++++++++++++++++ .../encrypt-environment-variable.ts | 24 +++++++- 5 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 libs/application-generic/src/encryption/encrypt-environment-variable.spec.ts diff --git a/apps/api/src/app/events/e2e/trigger-event.e2e.ts b/apps/api/src/app/events/e2e/trigger-event.e2e.ts index 4901095a69f..67754333179 100644 --- a/apps/api/src/app/events/e2e/trigger-event.e2e.ts +++ b/apps/api/src/app/events/e2e/trigger-event.e2e.ts @@ -632,6 +632,66 @@ describe('Trigger event - /v1/events/trigger (POST) #novu-v2', () => { } } }); + + it('should mask secret environment variables in delivered channel content', async () => { + const secretValue = `sk_live_delivery_exfil_${uuid()}`; + const publicValue = 'https://cdn.example.com/assets'; + + const createSecretResponse = await session.testAgent.post('/v1/environment-variables').send({ + key: 'DELIVERY_STRIPE_SECRET', + isSecret: true, + values: [{ _environmentId: session.environment._id, value: secretValue }], + }); + expect(createSecretResponse.status).to.equal(200); + + const createPublicResponse = await session.testAgent.post('/v1/environment-variables').send({ + key: 'DELIVERY_CDN_URL', + isSecret: false, + values: [{ _environmentId: session.environment._id, value: publicValue }], + }); + expect(createPublicResponse.status).to.equal(200); + + const workflowBody: CreateWorkflowDto = { + name: 'Secret Env Delivery Mask Workflow', + workflowId: `secret-env-delivery-mask-${uuid()}`, + __source: WorkflowCreationSourceEnum.DASHBOARD, + steps: [ + { + type: StepTypeEnum.IN_APP, + name: 'In-App Step', + controlValues: { + subject: 'Secret delivery check', + body: 'secret={{env.DELIVERY_STRIPE_SECRET}} public={{env.DELIVERY_CDN_URL}}', + }, + }, + ], + }; + + const workflowResponse = await session.testAgent.post('/v2/workflows').send(workflowBody); + expect(workflowResponse.status).to.equal(201); + const v2Workflow = workflowResponse.body.data as WorkflowResponseDto; + + await novuClient.trigger({ + workflowId: v2Workflow.workflowId, + to: [subscriber.subscriberId], + payload: {}, + }); + + await session.waitForJobCompletion(v2Workflow._id); + + const messages = await messageRepository.find({ + _environmentId: session.environment._id, + _subscriberId: subscriber._id, + channel: StepTypeEnum.IN_APP, + _templateId: v2Workflow._id, + }); + expect(messages.length).to.equal(1); + + const content = String(messages[0].content ?? ''); + expect(content).to.include(publicValue); + expect(content).to.include(SECRET_MASK); + expect(content).to.not.include(secretValue); + }); }); it('should digest events with filters', async () => { 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 da1d3b2b469..d331ce925c2 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 @@ -24,6 +24,7 @@ import { ChatProviderIdEnum, CronExpressionEnum, RedirectTargetEnum, + SECRET_MASK, StepTypeEnum, slugify, ToolProviderIdEnum, @@ -1706,6 +1707,43 @@ describe('Workflow Step Preview - POST /:workflowId/step/:stepId/preview #novu-v }); describe('payload sanitation', () => { + it('should mask secret environment variables in preview output (VULN-082)', async () => { + const secretValue = `sk_live_preview_exfil_${randomUUID()}`; + const publicValue = 'https://cdn.example.com'; + + const createSecretResponse = await session.testAgent.post('/v1/environment-variables').send({ + key: 'PREVIEW_STRIPE_SECRET', + isSecret: true, + values: [{ _environmentId: session.environment._id, value: secretValue }], + }); + expect(createSecretResponse.status).to.equal(200); + + const createPublicResponse = await session.testAgent.post('/v1/environment-variables').send({ + key: 'PREVIEW_CDN_URL', + isSecret: false, + values: [{ _environmentId: session.environment._id, value: publicValue }], + }); + expect(createPublicResponse.status).to.equal(200); + + const { stepDatabaseId, workflowId } = await createWorkflowAndReturnId(novuClient, StepTypeEnum.SMS); + const previewResponseDto = await generatePreview(novuClient, workflowId, stepDatabaseId, { + controlValues: { + body: 'secret={{env.PREVIEW_STRIPE_SECRET}} public={{env.PREVIEW_CDN_URL}}', + }, + }); + + expect(previewResponseDto.result!.preview).to.exist; + if (previewResponseDto.result!.type !== 'sms') { + throw new Error('Expected sms'); + } + + const previewBody = previewResponseDto.result!.preview.body; + expect(previewBody).to.include(publicValue); + expect(previewBody).to.include(SECRET_MASK); + expect(previewBody).to.not.include(secretValue); + expect(JSON.stringify(previewResponseDto)).to.not.include(secretValue); + }); + it('Should produce a correct payload when pipe is used etc {{payload.variable | upper}}', async () => { const { stepDatabaseId, workflowId } = await createWorkflowAndReturnId(novuClient, StepTypeEnum.SMS); const requestDto = { diff --git a/apps/worker/src/app/workflow/usecases/send-message/send-message.usecase.ts b/apps/worker/src/app/workflow/usecases/send-message/send-message.usecase.ts index 6c164052972..7aff12eca67 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/send-message.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/send-message.usecase.ts @@ -490,7 +490,8 @@ export class SendMessage { @Instrument() private async getEnvironmentVariables(command: SendMessageCommand): Promise> { - const cacheKey = `${command.organizationId}:${command.environmentId}`; + const includeSecrets = shouldIncludeEnvironmentSecrets(command.job?.type); + const cacheKey = `${command.organizationId}:${command.environmentId}:${includeSecrets ? 'full' : 'masked'}`; return this.inMemoryLRUCacheService.get( InMemoryLRUCacheStore.ENVIRONMENT_VARIABLES, @@ -502,7 +503,7 @@ export class SendMessage { command.environmentId ); - return resolveEnvironmentVariables(rawEnvVars); + return resolveEnvironmentVariables(rawEnvVars, { includeSecrets }); } catch (error) { Logger.warn( { err: error, organizationId: command.organizationId, environmentId: command.environmentId }, @@ -639,3 +640,12 @@ function requiresBridgeExecution(stepType: StepTypeEnum | undefined): boolean { return ![StepTypeEnum.TRIGGER, StepTypeEnum.DIGEST, StepTypeEnum.DELAY, StepTypeEnum.HTTP_REQUEST].includes(stepType); } + +/** + * Secret env vars stay masked for channel message rendering (email, SMS, etc.) + * so plaintext never reaches subscribers or the activity UI. Only outbound + * server-side steps that authenticate with those secrets may resolve them. + */ +function shouldIncludeEnvironmentSecrets(stepType: StepTypeEnum | string | undefined): boolean { + return stepType === StepTypeEnum.HTTP_REQUEST || stepType === StepTypeEnum.CUSTOM; +} diff --git a/libs/application-generic/src/encryption/encrypt-environment-variable.spec.ts b/libs/application-generic/src/encryption/encrypt-environment-variable.spec.ts new file mode 100644 index 00000000000..6a2a57768a9 --- /dev/null +++ b/libs/application-generic/src/encryption/encrypt-environment-variable.spec.ts @@ -0,0 +1,52 @@ +import { SECRET_MASK } from '@novu/shared'; +import { expect } from 'chai'; + +import { + decryptEnvironmentVariableValue, + resolveEnvironmentVariables, +} from './encrypt-environment-variable'; + +describe('encrypt-environment-variable', () => { + describe('resolveEnvironmentVariables', () => { + it('masks secret variables by default', () => { + const variables = [ + { key: 'PUBLIC_URL', value: 'https://example.com', isSecret: false }, + { key: 'API_KEY', value: 'plain-secret-value', isSecret: true }, + ]; + + const resolved = resolveEnvironmentVariables(variables); + + expect(resolved.PUBLIC_URL).to.equal('https://example.com'); + expect(resolved.API_KEY).to.equal(SECRET_MASK); + }); + + it('decrypts secret variables when includeSecrets is true', () => { + const variables = [ + { key: 'PUBLIC_URL', value: 'https://example.com', isSecret: false }, + { key: 'API_KEY', value: 'plain-secret-value', isSecret: true }, + ]; + + const resolved = resolveEnvironmentVariables(variables, { includeSecrets: true }); + + expect(resolved.PUBLIC_URL).to.equal('https://example.com'); + expect(resolved.API_KEY).to.equal('plain-secret-value'); + }); + + it('never returns plaintext secrets when includeSecrets is omitted', () => { + const variables = [{ key: 'API_KEY', value: 'sk_live_super_secret', isSecret: true }]; + + const masked = resolveEnvironmentVariables(variables); + const full = resolveEnvironmentVariables(variables, { includeSecrets: true }); + + expect(masked.API_KEY).to.equal(SECRET_MASK); + expect(full.API_KEY).to.equal('sk_live_super_secret'); + expect(JSON.stringify(masked)).to.not.include('sk_live_super_secret'); + }); + }); + + describe('decryptEnvironmentVariableValue', () => { + it('returns plaintext values unchanged', () => { + expect(decryptEnvironmentVariableValue('hello')).to.equal('hello'); + }); + }); +}); diff --git a/libs/application-generic/src/encryption/encrypt-environment-variable.ts b/libs/application-generic/src/encryption/encrypt-environment-variable.ts index af1b5b03196..8432a15fe9c 100644 --- a/libs/application-generic/src/encryption/encrypt-environment-variable.ts +++ b/libs/application-generic/src/encryption/encrypt-environment-variable.ts @@ -1,11 +1,21 @@ import { Logger } from '@nestjs/common'; import { EnvironmentVariableForTemplate } from '@novu/dal'; -import { NOVU_ENCRYPTION_SUB_MASK } from '@novu/shared'; +import { NOVU_ENCRYPTION_SUB_MASK, SECRET_MASK } from '@novu/shared'; import { decryptSecret } from './encrypt-provider'; const LOG_CONTEXT = 'DecryptEnvironmentVariable'; +export type ResolveEnvironmentVariablesOptions = { + /** + * When false (default), `isSecret` variables resolve to `SECRET_MASK` so they + * cannot appear in preview/API responses or channel message content. + * Set true only for server-side outbound execution (HTTP request / custom bridge) + * that must send real credentials and does not return them to API callers. + */ + includeSecrets?: boolean; +}; + export function decryptEnvironmentVariableValue(value: string): string { if (value.startsWith(NOVU_ENCRYPTION_SUB_MASK)) { try { @@ -20,11 +30,19 @@ export function decryptEnvironmentVariableValue(value: string): string { return value; } -export function resolveEnvironmentVariables(variables: EnvironmentVariableForTemplate[]): Record { +export function resolveEnvironmentVariables( + variables: EnvironmentVariableForTemplate[], + options: ResolveEnvironmentVariablesOptions = {} +): Record { + const includeSecrets = options.includeSecrets === true; const resolved: Record = {}; for (const variable of variables) { - resolved[variable.key] = decryptEnvironmentVariableValue(variable.value); + if (variable.isSecret && !includeSecrets) { + resolved[variable.key] = SECRET_MASK; + } else { + resolved[variable.key] = decryptEnvironmentVariableValue(variable.value); + } } return resolved;