From 92493e57a8a264ab0ee10a7868942cca82ae0677 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 31 Aug 2026 17:25:55 +0300 Subject: [PATCH 1/6] fix(api-service): Scope environment updates to API key environment fixes NV-8729 (#12510) Co-authored-by: Cursor Agent --- .../update-environment-api-key-scope.e2e.ts | 111 ++++++++++++++++++ .../environments-v1.controller.ts | 4 +- 2 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/app/environments-v1/e2e/update-environment-api-key-scope.e2e.ts diff --git a/apps/api/src/app/environments-v1/e2e/update-environment-api-key-scope.e2e.ts b/apps/api/src/app/environments-v1/e2e/update-environment-api-key-scope.e2e.ts new file mode 100644 index 00000000000..e8cae5d6b07 --- /dev/null +++ b/apps/api/src/app/environments-v1/e2e/update-environment-api-key-scope.e2e.ts @@ -0,0 +1,111 @@ +import { EnvironmentRepository } from '@novu/dal'; +import { ApiServiceLevelEnum, EnvironmentEnum } from '@novu/shared'; +import { UserSession } from '@novu/testing'; +import { expect } from 'chai'; + +describe('Update Environment API key environment scope - PUT /environments/:environmentId #novu-v2', () => { + let session: UserSession; + const environmentRepository = new EnvironmentRepository(); + + beforeEach(async () => { + session = new UserSession(); + await session.initialize(); + await session.updateOrganizationServiceLevel(ApiServiceLevelEnum.BUSINESS); + }); + + it('should forbid updating a sibling environment via API key', async () => { + const { + body: { data: createdEnv }, + } = await session.testAgent.post('/v1/environments').send({ + name: 'Sibling Env To Update', + color: '#ff0000', + }); + + expect(createdEnv._id, 'Expected custom environment to be created').to.exist; + + const { body } = await session.testAgent + .put(`/v1/environments/${createdEnv._id}`) + .set('authorization', `ApiKey ${session.apiKey}`) + .send({ + identifier: 'compromised-sibling', + color: '#0000ff', + bridge: { url: 'https://attacker.example/api/novu' }, + }); + + expect(body.statusCode).to.equal(403); + expect(body.message).to.contain('is scoped to a single environment'); + + const stored = await environmentRepository.findOne({ _id: createdEnv._id }); + expect(stored?.identifier).to.not.equal('compromised-sibling'); + expect(stored?.color).to.not.equal('#0000ff'); + expect(stored?.bridge?.url || stored?.echo?.url || '').to.not.equal('https://attacker.example/api/novu'); + }); + + it('should forbid updating Production via a Development API key', async () => { + const { + body: { data: environments }, + } = await session.testAgent.get('/v1/environments'); + const production = environments.find( + (environment: { name: string }) => environment.name === EnvironmentEnum.PRODUCTION + ); + + expect(production?._id, 'Expected Production environment').to.exist; + expect(production._id).to.not.equal(session.environment._id); + + const original = await environmentRepository.findOne({ _id: production._id }); + + const { body } = await session.testAgent + .put(`/v1/environments/${production._id}`) + .set('authorization', `ApiKey ${session.apiKey}`) + .send({ + identifier: 'compromised-production', + bridge: { url: 'https://attacker.example/api/novu' }, + }); + + expect(body.statusCode).to.equal(403); + expect(body.message).to.contain('is scoped to a single environment'); + + const stored = await environmentRepository.findOne({ _id: production._id }); + expect(stored?.identifier).to.equal(original?.identifier); + expect(stored?.bridge?.url || '').to.equal(original?.bridge?.url || ''); + expect(stored?.echo?.url || '').to.equal(original?.echo?.url || ''); + }); + + it('should allow an API key to update its own environment', async () => { + const { status } = await session.testAgent + .put(`/v1/environments/${session.environment._id}`) + .set('authorization', `ApiKey ${session.apiKey}`) + .send({ + identifier: 'own-env-via-api-key', + color: '#3366ff', + }); + + expect(status).to.equal(200); + + const stored = await environmentRepository.findOne({ _id: session.environment._id }); + expect(stored?.identifier).to.equal('own-env-via-api-key'); + expect(stored?.color).to.equal('#3366ff'); + }); + + it('should allow bearer auth to update a sibling environment in the same org', async () => { + const { + body: { data: createdEnv }, + } = await session.testAgent.post('/v1/environments').send({ + name: 'Bearer Updatable Env', + color: '#00ff00', + }); + + expect(createdEnv._id, 'Expected custom environment to be created').to.exist; + + const { status } = await session.testAgent.put(`/v1/environments/${createdEnv._id}`).send({ + identifier: 'bearer-updated-sibling', + color: '#112233', + }); + + expect(status).to.equal(200); + + const stored = await environmentRepository.findOne({ _id: createdEnv._id }); + expect(stored?.identifier).to.equal('bearer-updated-sibling'); + expect(stored?.color).to.equal('#112233'); + }); +}); diff --git a/apps/api/src/app/environments-v1/environments-v1.controller.ts b/apps/api/src/app/environments-v1/environments-v1.controller.ts index 7a9f1eaa6b8..9ee67580128 100644 --- a/apps/api/src/app/environments-v1/environments-v1.controller.ts +++ b/apps/api/src/app/environments-v1/environments-v1.controller.ts @@ -36,7 +36,7 @@ import { ApiKey } from '../shared/dtos/api-key'; import { ApiCommonResponses, ApiResponse } from '../shared/framework/response.decorator'; import { SdkGroupName, SdkMethodName } from '../shared/framework/swagger/sdk.decorators'; import { UserSession } from '../shared/framework/user.decorator'; -import { isEnvironmentScopedAuthScheme } from '../shared/utils/auth.utils'; +import { assertEnvironmentScopedAccess, isEnvironmentScopedAuthScheme } from '../shared/utils/auth.utils'; import { CreateEnvironmentRequestDto } from './dtos/create-environment-request.dto'; import { EnvironmentResponseDto } from './dtos/environment-response.dto'; import { UpdateEnvironmentRequestDto } from './dtos/update-environment-request.dto'; @@ -190,6 +190,8 @@ export class EnvironmentsControllerV1 { @Param('environmentId') environmentId: string, @Body() payload: UpdateEnvironmentRequestDto ) { + assertEnvironmentScopedAccess(user.scheme, user.environmentId, environmentId); + return await this.updateEnvironmentUsecase.execute( UpdateEnvironmentCommand.create({ environmentId, From c888483c04ae9422d9beedbb3cb5ca09b9f8c0b1 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 31 Aug 2026 17:26:49 +0300 Subject: [PATCH 2/6] Update agents.mdx with title and description Add title and description for Novu Connect documentation. --- docs/agents.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/agents.mdx b/docs/agents.mdx index 8afaacd2c7b..e2804f8782c 100644 --- a/docs/agents.mdx +++ b/docs/agents.mdx @@ -1,3 +1,4 @@ +--- title: "Novu Connect - Agent Communication Infrastructure (ACI)" description: "Connect AI agents to Slack, Teams, WhatsApp, Telegram, and email. Route inbound messages, preserve conversation context, and deliver replies through Novu ACI." sidebarTitle: Overview From ed1c7dee197058cc190456aeb582ad99d6dd150b Mon Sep 17 00:00:00 2001 From: Adam Chmara Date: Mon, 31 Aug 2026 17:00:28 +0200 Subject: [PATCH 3/6] fix(novu): omit apiUrl in merged Web Chat scaffold for US Cloud fixes NV-8731 (#12512) --- .../web-chat/scaffold-web-chat.spec.ts | 35 +++++++++++++++++++ .../pipeline/web-chat/scaffold-web-chat.ts | 9 +++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/novu/src/commands/connect/pipeline/web-chat/scaffold-web-chat.spec.ts b/packages/novu/src/commands/connect/pipeline/web-chat/scaffold-web-chat.spec.ts index ab33e202dfc..3adff568e14 100644 --- a/packages/novu/src/commands/connect/pipeline/web-chat/scaffold-web-chat.spec.ts +++ b/packages/novu/src/commands/connect/pipeline/web-chat/scaffold-web-chat.spec.ts @@ -42,6 +42,41 @@ describe('scaffoldWebChatProject', () => { }) ).rejects.toThrow(/Invalid scaffold directory name/); }); + + it('does not hardcode localhost when merging Web Chat into an existing project', async () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'novu-web-chat-merge-')); + fs.writeFileSync( + path.join(projectDir, 'package.json'), + JSON.stringify( + { + name: 'agent-app', + dependencies: { + '@novu/react': 'latest', + '@novu/js': 'latest', + 'react-markdown': '^10.1.0', + 'remark-gfm': '^4.0.1', + }, + }, + null, + 2 + ) + ); + + await scaffoldWebChatProject({ + parentDir: projectDir, + agentIdentifier: 'support-agent', + applicationIdentifier: 'app-id', + subscriberId: 'subscriber-id', + apiUrl: 'https://api.novu.co', + mergeIntoProjectDir: projectDir, + mergeAtRoot: true, + }); + + const page = fs.readFileSync(path.join(projectDir, 'app', 'page.tsx'), 'utf8'); + expect(page).not.toContain('localhost:3000'); + expect(page).toContain('...(apiUrl ? { apiUrl } : {})'); + expect(page).toContain('...(socketUrl ? { socketUrl } : {})'); + }); }); describe('resolveWebChatNovuDependencies', () => { diff --git a/packages/novu/src/commands/connect/pipeline/web-chat/scaffold-web-chat.ts b/packages/novu/src/commands/connect/pipeline/web-chat/scaffold-web-chat.ts index df554c30af2..7629de6198d 100644 --- a/packages/novu/src/commands/connect/pipeline/web-chat/scaffold-web-chat.ts +++ b/packages/novu/src/commands/connect/pipeline/web-chat/scaffold-web-chat.ts @@ -246,11 +246,16 @@ const inter = Inter({ subsets: ['latin'], display: 'swap' }); export default function WebChatPage() { const applicationIdentifier = process.env.NEXT_PUBLIC_NOVU_APP_ID ?? ''; const subscriberId = process.env.NEXT_PUBLIC_NOVU_SUBSCRIBER_ID ?? ''; - const apiUrl = process.env.NEXT_PUBLIC_NOVU_BACKEND_URL ?? 'http://localhost:3000'; + const apiUrl = process.env.NEXT_PUBLIC_NOVU_BACKEND_URL; const socketUrl = process.env.NEXT_PUBLIC_NOVU_SOCKET_URL; return ( - +
From 80ebaa666ed74333e85d6c62199d209a8fe77dc7 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 31 Aug 2026 18:09:06 +0300 Subject: [PATCH 4/6] fix(docs): restore agents page frontmatter and agent-chat redirects fixes NV-8730 (#12511) Co-authored-by: Cursor Agent --- docs/docs.json | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/docs/docs.json b/docs/docs.json index 1d2154cdad0..02400dc9c21 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1721,36 +1721,86 @@ "destination": "/agents/channels/overview", "permanent": true }, + { + "source": "/platform/integrations/chat/agent-chat", + "destination": "/agents/channels/web-chat", + "permanent": true + }, { "source": "/platform/integrations/chat/web-chat", "destination": "/agents/channels/web-chat", "permanent": true }, + { + "source": "/agents/agent-chat", + "destination": "/agents/channels/web-chat", + "permanent": true + }, { "source": "/agents/web-chat", "destination": "/agents/channels/web-chat", "permanent": true }, + { + "source": "/agents/agent-chat/quickstart", + "destination": "/agents/channels/web-chat/quickstart", + "permanent": true + }, { "source": "/agents/web-chat/quickstart", "destination": "/agents/channels/web-chat/quickstart", "permanent": true }, + { + "source": "/agents/channels/agent-chat", + "destination": "/agents/channels/web-chat", + "permanent": true + }, + { + "source": "/agents/channels/agent-chat/quickstart", + "destination": "/agents/channels/web-chat/quickstart", + "permanent": true + }, + { + "source": "/agents/channels/agent-chat/chat-ui", + "destination": "/agents/channels/web-chat/chat-ui", + "permanent": true + }, + { + "source": "/agents/channels/agent-chat/rendering", + "destination": "/agents/channels/web-chat/chat-ui", + "permanent": true + }, { "source": "/agents/channels/web-chat/rendering", "destination": "/agents/channels/web-chat/chat-ui", "permanent": true }, + { + "source": "/agents/channels/agent-chat/production", + "destination": "/agents/channels/web-chat", + "permanent": true + }, { "source": "/agents/channels/web-chat/production", "destination": "/agents/channels/web-chat", "permanent": true }, + { + "source": "/agents/channels/agent-chat/messages", + "destination": "/agents/channels/web-chat/chat-ui", + "permanent": true + }, { "source": "/agents/channels/web-chat/messages", "destination": "/agents/channels/web-chat/chat-ui", "permanent": true }, + { + "source": "/platform/sdks/react/hooks/use-agent-chat", + "destination": "/platform/sdks/react/hooks/use-web-chat", + "permanent": true + }, { "source": "/agents/custom-code-agent/build-your-first-agent", "destination": "/agents/custom-code-agent/frameworks/ai-sdk", From 3d4cb7e08704a6438e58c8d1eab51e01b2971952 Mon Sep 17 00:00:00 2001 From: Adam Chmara Date: Mon, 31 Aug 2026 17:31:58 +0200 Subject: [PATCH 5/6] docs(web-chat): switch from closed beta to open Beta badge fixes DOC-438 (#12513) Co-authored-by: Cursor --- docs/agents/channels/web-chat.mdx | 4 ---- docs/agents/channels/web-chat/chat-ui.mdx | 4 ---- docs/agents/channels/web-chat/quickstart.mdx | 4 ---- docs/docs.json | 1 + docs/platform/sdks/react/hooks/use-web-chat.mdx | 5 +---- 5 files changed, 2 insertions(+), 16 deletions(-) diff --git a/docs/agents/channels/web-chat.mdx b/docs/agents/channels/web-chat.mdx index b42ca809cb6..8424300b328 100644 --- a/docs/agents/channels/web-chat.mdx +++ b/docs/agents/channels/web-chat.mdx @@ -4,10 +4,6 @@ description: "Connect your agent to in-app Web Chat: a headless React hook, live sidebarTitle: Overview --- - - Web Chat is in closed beta. Contact us at support@novu.co to get access. - - Web Chat is an ACI channel. The messaging surface is your product UI. The agent brain, conversation history, tool approval, and dashboard observability match other ACI channels. The React client is [`useWebChat`](/platform/sdks/react/hooks/use-web-chat) from `@novu/react`. You render the message list and the composer. There is no prebuilt `` component. diff --git a/docs/agents/channels/web-chat/chat-ui.mdx b/docs/agents/channels/web-chat/chat-ui.mdx index a4911707c5b..ec80883f76d 100644 --- a/docs/agents/channels/web-chat/chat-ui.mdx +++ b/docs/agents/channels/web-chat/chat-ui.mdx @@ -4,10 +4,6 @@ description: "Web Chat client: state, parts, thinking, cards, approvals, custom sidebarTitle: Chat UI --- - - Web Chat is in closed beta. Contact us at support@novu.co to get access. - - Start from [Send a message](/agents/channels/web-chat/quickstart#send-a-message). This page covers everything after the first message. `messages` is the ordered timeline. Each message has `role` (`user` or `assistant`) and `parts`. diff --git a/docs/agents/channels/web-chat/quickstart.mdx b/docs/agents/channels/web-chat/quickstart.mdx index 9494c254d03..1992e044042 100644 --- a/docs/agents/channels/web-chat/quickstart.mdx +++ b/docs/agents/channels/web-chat/quickstart.mdx @@ -4,10 +4,6 @@ description: "Install @novu/react, wrap NovuProvider, and use useWebChat to send sidebarTitle: Quickstart --- - - Web Chat is in closed beta. Contact us at support@novu.co to get access. - - Build a two-way Web Chat in your React app. You supply the UI. Novu supplies conversation state, delivery, and the live socket. ## Prerequisites diff --git a/docs/docs.json b/docs/docs.json index 02400dc9c21..17e5be79059 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -469,6 +469,7 @@ "agents/channels/email", { "group": "Web Chat", + "tag": "Beta", "root": "agents/channels/web-chat", "pages": ["agents/channels/web-chat/quickstart", "agents/channels/web-chat/chat-ui"] } diff --git a/docs/platform/sdks/react/hooks/use-web-chat.mdx b/docs/platform/sdks/react/hooks/use-web-chat.mdx index e112f3316af..0ba37b95631 100644 --- a/docs/platform/sdks/react/hooks/use-web-chat.mdx +++ b/docs/platform/sdks/react/hooks/use-web-chat.mdx @@ -1,12 +1,9 @@ --- title: "useWebChat" description: "API reference for the useWebChat hook: conversation state, sendMessage, retry, tool approval, live typing, and history pagination in the Novu React SDK." +tag: Beta --- - - Web Chat is in closed beta. Contact us at support@novu.co to get access. - - The `useWebChat` hook is the headless client for [Web Chat](/agents/channels/web-chat). It loads conversation history, sends messages, streams live turns over the socket, and exposes pending tool approvals. Use it inside [`NovuProvider`](/platform/sdks/react/hooks/novu-provider). See [Quickstart](/agents/channels/web-chat/quickstart), [Chat UI](/agents/channels/web-chat/chat-ui), and [State](/agents/channels/web-chat/chat-ui#state). From bdf8e519e76775084e15e02600afe58875aba972 Mon Sep 17 00:00:00 2001 From: Pawan Jain Date: Mon, 31 Aug 2026 22:48:30 +0530 Subject: [PATCH 6/6] fix(api-service): omit empty dynamicKey on fixed throttle steps fixes NV-8728 (#12509) Co-authored-by: Cursor --- .../src/utils/sanitize-control-values.spec.ts | 36 +++++++++++++++++++ .../src/utils/sanitize-control-values.ts | 16 +++++++++ 2 files changed, 52 insertions(+) diff --git a/libs/application-generic/src/utils/sanitize-control-values.spec.ts b/libs/application-generic/src/utils/sanitize-control-values.spec.ts index f0ffcdf09b5..2cf6fef2783 100644 --- a/libs/application-generic/src/utils/sanitize-control-values.spec.ts +++ b/libs/application-generic/src/utils/sanitize-control-values.spec.ts @@ -58,4 +58,40 @@ describe('dashboardSanitizeControlValues', () => { expect(sanitized).not.toHaveProperty('editorType'); }); + + it('omits an empty throttle dynamicKey for a fixed throttle', () => { + const sanitized = dashboardSanitizeControlValues( + logger, + { type: 'fixed', amount: 1, unit: 'hours', dynamicKey: '', threshold: 1, throttleKey: '{{payload.alertKey}}' }, + StepTypeEnum.THROTTLE + ); + + expect(sanitized).toEqual({ + type: 'fixed', + amount: 1, + unit: 'hours', + threshold: 1, + throttleKey: '{{payload.alertKey}}', + }); + }); + + it('keeps an empty throttle dynamicKey for a dynamic throttle so the issue still surfaces', () => { + const sanitized = dashboardSanitizeControlValues( + logger, + { type: 'dynamic', dynamicKey: '', threshold: 1 }, + StepTypeEnum.THROTTLE + ); + + expect(sanitized).toEqual({ type: 'dynamic', dynamicKey: '', threshold: 1 }); + }); + + it('keeps a populated throttle dynamicKey regardless of type', () => { + const sanitized = dashboardSanitizeControlValues( + logger, + { type: 'fixed', amount: 1, unit: 'hours', dynamicKey: 'payload.timestamp' }, + StepTypeEnum.THROTTLE + ); + + expect(sanitized).toEqual({ type: 'fixed', amount: 1, unit: 'hours', dynamicKey: 'payload.timestamp' }); + }); }); diff --git a/libs/application-generic/src/utils/sanitize-control-values.ts b/libs/application-generic/src/utils/sanitize-control-values.ts index 6e693820f6d..8195311ec41 100644 --- a/libs/application-generic/src/utils/sanitize-control-values.ts +++ b/libs/application-generic/src/utils/sanitize-control-values.ts @@ -16,6 +16,7 @@ import { LookBackWindowType, PushControlType, SmsControlType, + ThrottleControlType, ToolControlType, } from '../schemas/control'; import { InAppActionType, InAppControlType } from '../schemas/control/in-app-control.schema'; @@ -253,6 +254,18 @@ function sanitizeDelay(controlValues: DelayControlType) { return filterNullishValues(controlValues); } +/** + * A fixed throttle never reads `dynamicKey`, but the dashboard form still persists it as an empty + * string. The control schema keeps `dynamicKey` optional with `minLength: 1`, so a present-but-empty + * value fails validation and surfaces a "DynamicKey is required" issue on a correctly configured + * fixed throttle. Drop the unused key; a dynamic throttle keeps it so the issue still surfaces there. + */ +function sanitizeThrottle(controlValues: ThrottleControlType) { + const shouldDropDynamicKey = controlValues?.type !== 'dynamic' && isEmpty(controlValues?.dynamicKey); + + return filterNullishValues(shouldDropDynamicKey ? { ...controlValues, dynamicKey: undefined } : controlValues); +} + function sanitizeLayout(controlValues: LayoutControlType) { return { email: filterNullishValues({ @@ -361,6 +374,9 @@ export function dashboardSanitizeControlValues( case StepTypeEnum.DELAY: normalizedValues = sanitizeDelay(controlValues as DelayControlType); break; + case StepTypeEnum.THROTTLE: + normalizedValues = sanitizeThrottle(controlValues as ThrottleControlType); + break; case 'layout': normalizedValues = sanitizeLayout(controlValues as LayoutControlType); break;