diff --git a/.vscode/settings.json b/.vscode/settings.json index 405eef63f15..e48e57ba9f8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,6 +3,8 @@ "jest.enable": false, "editor.defaultFormatter": "biomejs.biome", "editor.formatOnSave": true, + "typescript.preferences.quoteStyle": "single", + "javascript.preferences.quoteStyle": "single", "editor.tabSize": 2, "files.insertFinalNewline": true, "files.trimFinalNewlines": true, diff --git a/apps/api/src/app/agents/agents.module.ts b/apps/api/src/app/agents/agents.module.ts index 13b81ea1b12..0b6464c79e7 100644 --- a/apps/api/src/app/agents/agents.module.ts +++ b/apps/api/src/app/agents/agents.module.ts @@ -9,6 +9,7 @@ import { } from '@novu/application-generic'; import { AgentMcpServerRepository, + AgentToolTrustRepository, ChannelConnectionRepository, ChannelEndpointRepository, CommunityOrganizationRepository, @@ -61,11 +62,12 @@ import { ManagedAgentService } from './managed-runtime/managed-agent.service'; import { ManagedAgentEventHandler } from './managed-runtime/managed-agent-event-handler.service'; import { ManagedAgentProviderFactory } from './managed-runtime/managed-agent-provider-factory.service'; import { ManagedRuntimeController } from './managed-runtime/managed-runtime.controller'; +import { ToolTrustService } from './managed-runtime/tool-approval/tool-trust.service'; import { AgentRuntimeController } from './management/agent-runtime.controller'; import { AgentsController } from './management/agents.controller'; import { McpNovuAppCredentialsService } from './mcp/connections/get-mcp-novu-app-credentials/get-mcp-novu-app-credentials.service'; -import { McpConnectionVaultService } from './mcp/connections/mcp-connection-vault.service'; import { McpConnectRedirectService } from './mcp/connections/mcp-connect-redirect.service'; +import { McpConnectionVaultService } from './mcp/connections/mcp-connection-vault.service'; import { AgentsMcpOAuthController } from './mcp/oauth/agents-mcp-oauth.controller'; import { McpOAuthDiscoveryService } from './mcp/oauth/mcp-oauth-discovery.service'; import { AgentMcpDefinitionService } from './mcp/runtime/agent-mcp-definition.service'; @@ -98,6 +100,7 @@ import { USE_CASES } from './usecases'; ...USE_CASES, AgentRuntimeExceptionFilter, AgentMcpServerRepository, + AgentToolTrustRepository, ChannelConnectionRepository, ChannelEndpointRepository, CommunityOrganizationRepository, @@ -124,6 +127,7 @@ import { USE_CASES } from './usecases'; ManagedAgentProviderFactory, ManagedAgentEventHandler, ManagedAgentService, + ToolTrustService, McpConnectionVaultService, McpConnectRedirectService, AgentMcpDefinitionService, diff --git a/apps/api/src/app/agents/managed-runtime/tool-approval/approval-card.builder.ts b/apps/api/src/app/agents/managed-runtime/tool-approval/approval-card.builder.ts index 93d3f6d4013..0853767b217 100644 --- a/apps/api/src/app/agents/managed-runtime/tool-approval/approval-card.builder.ts +++ b/apps/api/src/app/agents/managed-runtime/tool-approval/approval-card.builder.ts @@ -6,27 +6,12 @@ import type { SlackNativeDelivery } from '../../conversation-runtime/egress/slac import type { ReplyContentDto } from '../../shared/dtos/agent-reply-payload.dto'; import { AgentPlatformEnum } from '../../shared/enums/agent-platform.enum'; -export const TOOL_APPROVAL_ACTION_PREFIX = 'mcp-approval' as const; +const MCP_TOOL_APPROVAL_ACTION_PREFIX = 'mcp-approval' as const; +const DIRECT_TOOL_APPROVAL_ACTION_PREFIX = 'direct-approval' as const; -function resolveDashboardBaseUrl(): string { - for (const candidate of [process.env.DASHBOARD_URL, process.env.FRONT_BASE_URL]) { - const trimmed = candidate?.trim(); - - if (!trimmed || trimmed.startsWith('^')) { - continue; - } - - return trimmed.replace(/\/$/, ''); - } - - return 'https://dashboard.novu.co'; -} - -function resolveSlackMcpIconUrl(mcpServerName?: string): string { - const mcpId = resolveMcpCatalogIdByName(mcpServerName) ?? MCP_ICON_DEFAULT_ID; +type ToolApprovalActionPrefix = typeof MCP_TOOL_APPROVAL_ACTION_PREFIX | typeof DIRECT_TOOL_APPROVAL_ACTION_PREFIX; - return getMcpIconUrl(mcpId, resolveDashboardBaseUrl()); -} +const SLACK_CARD_BODY_MAX = 200; type SlackCardBlock = Block & { type: 'card'; @@ -48,59 +33,187 @@ export type ManagedCardDelivery = { slackNative?: SlackNativeDelivery; }; -// Slack button clicks include action.id (parsed below) and action.value (label text only). -// Id: mcp-approval:{approve|deny}:{toolUseId} -// "Always allow" buttons: mcp-approval:{approve-tool|approve-server}:{toolUseId}:{toolName}:{mcpServerName} -export type ParsedToolApprovalAction = { - approved: boolean; - toolUseId: string; - persistScope?: 'tool' | 'server'; - toolName?: string; - mcpServerName?: string; -}; +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- -export function parseToolApprovalActionId(id: string | undefined): ParsedToolApprovalAction | null { - if (!id) return null; - const parts = id.split(':'); - if (parts[0] !== TOOL_APPROVAL_ACTION_PREFIX) return null; - if (parts.length !== 3 && parts.length !== 5) return null; +function resolveDashboardBaseUrl(): string { + for (const candidate of [process.env.DASHBOARD_URL, process.env.FRONT_BASE_URL]) { + const trimmed = candidate?.trim(); + + if (!trimmed || trimmed.startsWith('^')) { + continue; + } - const verdict = parts[1]; - const toolUseId = parts[2]; - const isApprove = verdict === 'approve' || verdict === 'approve-tool' || verdict === 'approve-server'; - const isDeny = verdict === 'deny'; + return trimmed.replace(/\/$/, ''); + } + + return 'https://dashboard.novu.co'; +} - if ((!isApprove && !isDeny) || !toolUseId) return null; +function resolveSlackMcpIconUrl(mcpServerName?: string): string { + const mcpId = resolveMcpCatalogIdByName(mcpServerName) ?? MCP_ICON_DEFAULT_ID; - const parsed: ParsedToolApprovalAction = { - approved: isApprove, - toolUseId, - }; + return getMcpIconUrl(mcpId, resolveDashboardBaseUrl()); +} - if (verdict === 'approve-tool') { - parsed.persistScope = 'tool'; - } +function summariseInput(input: Record): string { + const firstValue = Object.values(input)[0]; + if (firstValue === undefined) return ''; + const text = typeof firstValue === 'string' ? firstValue : JSON.stringify(firstValue); + + return text.length > 80 ? `${text.slice(0, 77)}...` : text; +} - if (verdict === 'approve-server') { - parsed.persistScope = 'server'; +function summariseInputSuffix(tool: PendingToolApproval): string { + return tool.input ? `: ${summariseInput(tool.input)}` : ''; +} + +function mcpToolLabel(tool: PendingToolApproval): string { + return `${tool.mcpServerName} -> ${tool.toolName}${summariseInputSuffix(tool)}`; +} + +function directToolLabel(tool: PendingToolApproval): string { + return `${tool.toolName}${summariseInputSuffix(tool)}`; +} + +function formatToolArgumentsBody(tool: PendingToolApproval): string | undefined { + if (!tool.input || Object.keys(tool.input).length === 0) { + return undefined; } - if (parts.length === 5) { - parsed.toolName = decodeURIComponent(parts[3]) || undefined; - parsed.mcpServerName = decodeURIComponent(parts[4]) || undefined; + const compact = JSON.stringify(tool.input); + const body = `*Arguments*\n\`\`\`\n${compact}\n\`\`\``; + + if (body.length <= SLACK_CARD_BODY_MAX) { + return body; } - return parsed; + const truncatedJson = `${compact.slice(0, SLACK_CARD_BODY_MAX - 20)}…`; + const truncatedBody = `*Arguments*\n\`\`\`\n${truncatedJson}\n\`\`\``; + + return truncatedBody.length <= SLACK_CARD_BODY_MAX ? truncatedBody : truncatedBody.slice(0, SLACK_CARD_BODY_MAX); +} + +// --------------------------------------------------------------------------- +// Action id grammar (kept in sync with parseToolApprovalActionId) +// --------------------------------------------------------------------------- + +export type ToolTrustTarget = + | { scope: 'tool'; toolName: string; mcpServerName?: string } + | { scope: 'server'; mcpServerName: string }; + +export type ParsedToolApprovalAction = { + toolUseId: string; + approved: boolean; + trust?: ToolTrustTarget; +}; + +function buildToolApprovalActionId( + prefix: ToolApprovalActionPrefix, + verdict: 'approve' | 'deny', + toolUseId: string +): string { + return `${prefix}:${verdict}:${toolUseId}`; } -export function buildToolApprovalPersistActionId( +function buildMcpToolApprovalPersistActionId( verdict: 'approve-tool' | 'approve-server', tool: PendingToolApproval ): string { const toolName = encodeURIComponent(tool.toolName); const mcpServerName = encodeURIComponent(tool.mcpServerName ?? ''); - return `${TOOL_APPROVAL_ACTION_PREFIX}:${verdict}:${tool.toolUseId}:${toolName}:${mcpServerName}`; + return `${MCP_TOOL_APPROVAL_ACTION_PREFIX}:${verdict}:${tool.toolUseId}:${toolName}:${mcpServerName}`; +} + +function buildDirectToolApprovalPersistActionId(tool: PendingToolApproval): string { + const toolName = encodeURIComponent(tool.toolName); + + return `${DIRECT_TOOL_APPROVAL_ACTION_PREFIX}:approve-tool:${tool.toolUseId}:${toolName}`; +} + +const TOOL_APPROVAL_VERDICTS = ['approve', 'deny', 'approve-tool', 'approve-server'] as const; +type ToolApprovalVerdict = (typeof TOOL_APPROVAL_VERDICTS)[number]; + +function isToolApprovalPrefix(value: string | undefined): value is ToolApprovalActionPrefix { + return value === MCP_TOOL_APPROVAL_ACTION_PREFIX || value === DIRECT_TOOL_APPROVAL_ACTION_PREFIX; +} + +function isToolApprovalVerdict(value: string | undefined): value is ToolApprovalVerdict { + return TOOL_APPROVAL_VERDICTS.includes(value as ToolApprovalVerdict); +} + +function decodeSegment(segment: string | undefined): string | undefined { + if (!segment) { + return undefined; + } + + try { + return decodeURIComponent(segment) || undefined; + } catch { + // Malformed percent-encoding: ignore the segment rather than throwing and + // breaking the whole approval action handler. + return undefined; + } +} + +/** + * Action ids are colon-joined: `{prefix}:{verdict}:{toolUseId}[:{toolName}[:{mcpServerName}]]`. + * `toolName` / `mcpServerName` are URL-encoded, so they never contain a colon. + */ +export function parseToolApprovalActionId(id: string | undefined): ParsedToolApprovalAction | null { + const [prefix, verdict, toolUseId, rawToolName, rawServerName, ...rest] = (id ?? '').split(':'); + + if (rest.length > 0 || !isToolApprovalPrefix(prefix) || !isToolApprovalVerdict(verdict) || !toolUseId) { + return null; + } + + const toolName = decodeSegment(rawToolName); + const mcpServerName = decodeSegment(rawServerName); + const approved = verdict !== 'deny'; + // The trust *source* is bound to the action prefix, never inferred from the + // segments present. This prevents a forged/mismatched action id (e.g. a + // direct prefix with `approve-server`) from persisting MCP server-wide trust. + const isMcp = prefix === MCP_TOOL_APPROVAL_ACTION_PREFIX; + + switch (verdict) { + case 'approve': + case 'deny': + return { toolUseId, approved }; + // Persist verdicts are only ever emitted by our cards with all required + // segments present. A missing/undecodable segment therefore means a + // malformed or forged id, so we reject it (fail closed: no approval, no + // persist) rather than downgrading it to a one-off approval. + case 'approve-tool': { + // MCP per-tool trust must carry its server; direct tool trust must not. + if (isMcp) { + if (!toolName || !mcpServerName) { + return null; + } + + return { toolUseId, approved, trust: { scope: 'tool', toolName, mcpServerName } }; + } + + if (!toolName) { + return null; + } + + return { toolUseId, approved, trust: { scope: 'tool', toolName } }; + } + case 'approve-server': { + // Server-wide trust only exists on MCP cards. + if (!isMcp || !mcpServerName) { + return null; + } + + return { toolUseId, approved, trust: { scope: 'server', mcpServerName } }; + } + default: { + const exhaustive: never = verdict; + throw new Error(`Unhandled tool approval verdict: ${exhaustive}`); + } + } } export function extractPendingToolApprovals(response: ThalamusResponse): PendingToolApproval[] { @@ -117,18 +230,12 @@ export function extractPendingToolApprovals(response: ThalamusResponse): Pending })); } -export function formatToolLabelForApproval(tool: PendingToolApproval): string { - const input = tool.input ? `: ${summariseInput(tool.input)}` : ''; +// --------------------------------------------------------------------------- +// Card builders (MCP and direct are intentionally kept separate) +// --------------------------------------------------------------------------- - if (tool.mcpServerName) { - return `${tool.mcpServerName} -> ${tool.toolName}${input}`; - } - - return `${tool.toolName}${input}`; -} - -export function buildToolApprovalCard(tool: PendingToolApproval): Record { - const toolLabel = formatToolLabelForApproval(tool); +function buildMcpToolApprovalCard(tool: PendingToolApproval): Record { + const toolLabel = mcpToolLabel(tool); const children: Record[] = [ { @@ -136,14 +243,14 @@ export function buildToolApprovalCard(tool: PendingToolApproval): Record { + const toolLabel = directToolLabel(tool); - return tool.toolName; -} - -function formatToolArgumentsBody(tool: PendingToolApproval): string | undefined { - if (!tool.input || Object.keys(tool.input).length === 0) { - return undefined; - } - - const compact = JSON.stringify(tool.input); - const body = `*Arguments*\n\`\`\`\n${compact}\n\`\`\``; - - if (body.length <= SLACK_CARD_BODY_MAX) { - return body; - } - - const truncatedJson = `${compact.slice(0, SLACK_CARD_BODY_MAX - 20)}…`; - const truncatedBody = `*Arguments*\n\`\`\`\n${truncatedJson}\n\`\`\``; + const children: Record[] = [ + { + type: 'actions', + children: [ + { + type: 'button', + id: buildToolApprovalActionId(DIRECT_TOOL_APPROVAL_ACTION_PREFIX, 'deny', tool.toolUseId), + label: 'Deny', + style: 'default', + value: toolLabel, + }, + { + type: 'button', + id: buildToolApprovalActionId(DIRECT_TOOL_APPROVAL_ACTION_PREFIX, 'approve', tool.toolUseId), + label: 'Approve once', + style: 'primary', + value: toolLabel, + }, + ], + }, + { type: 'divider' }, + { + type: 'actions', + children: [ + { + type: 'button', + id: buildDirectToolApprovalPersistActionId(tool), + label: 'Always allow this tool', + style: 'default', + value: toolLabel, + }, + ], + }, + ]; - return truncatedBody.length <= SLACK_CARD_BODY_MAX ? truncatedBody : truncatedBody.slice(0, SLACK_CARD_BODY_MAX); + return { + type: 'card', + title: 'Tool approval required', + subtitle: toolLabel, + children, + }; } -function buildToolApprovalSlackBlocks(tool: PendingToolApproval): SlackNativeDelivery { +function buildMcpToolApprovalSlackBlocks(tool: PendingToolApproval): SlackNativeDelivery { const argumentsBody = formatToolArgumentsBody(tool); - const toolLabel = formatToolLabelForApproval(tool); + const toolLabel = mcpToolLabel(tool); const cardBlock: SlackCardBlock = { type: 'card', @@ -220,19 +345,19 @@ function buildToolApprovalSlackBlocks(tool: PendingToolApproval): SlackNativeDel alt_text: tool.mcpServerName ?? 'Tool', }, title: { type: 'mrkdwn', text: 'Tool approval required', verbatim: false }, - subtitle: { type: 'mrkdwn', text: formatToolSubtitle(tool), verbatim: false }, + subtitle: { type: 'mrkdwn', text: `${tool.mcpServerName} · ${tool.toolName}`, verbatim: false }, ...(argumentsBody ? { body: { type: 'mrkdwn', text: argumentsBody, verbatim: false } } : {}), actions: [ { type: 'button', - action_id: `${TOOL_APPROVAL_ACTION_PREFIX}:deny:${tool.toolUseId}`, + action_id: buildToolApprovalActionId(MCP_TOOL_APPROVAL_ACTION_PREFIX, 'deny', tool.toolUseId), value: toolLabel, text: { type: 'plain_text', text: 'Deny', emoji: false }, }, { type: 'button', style: 'primary', - action_id: `${TOOL_APPROVAL_ACTION_PREFIX}:approve:${tool.toolUseId}`, + action_id: buildToolApprovalActionId(MCP_TOOL_APPROVAL_ACTION_PREFIX, 'approve', tool.toolUseId), value: toolLabel, text: { type: 'plain_text', text: 'Approve once', emoji: false }, }, @@ -244,23 +369,15 @@ function buildToolApprovalSlackBlocks(tool: PendingToolApproval): SlackNativeDel elements: [ { type: 'button', - action_id: buildToolApprovalPersistActionId('approve-tool', tool), + action_id: buildMcpToolApprovalPersistActionId('approve-tool', tool), value: toolLabel, - text: { - type: 'plain_text', - text: 'Always allow this tool', - emoji: false, - }, + text: { type: 'plain_text', text: 'Always allow this tool', emoji: false }, }, { type: 'button', - action_id: buildToolApprovalPersistActionId('approve-server', tool), + action_id: buildMcpToolApprovalPersistActionId('approve-server', tool), value: toolLabel, - text: { - type: 'plain_text', - text: `Always allow ${tool.mcpServerName ?? 'MCP'}`, - emoji: false, - }, + text: { type: 'plain_text', text: `Always allow ${tool.mcpServerName}`, emoji: false }, }, ], }; @@ -271,28 +388,73 @@ function buildToolApprovalSlackBlocks(tool: PendingToolApproval): SlackNativeDel }; } -export function getToolApprovalCard(params: { - platform?: string; - tool: PendingToolApproval; - pendingQueueTotal?: number; -}): ManagedCardDelivery { - const card = buildToolApprovalCard(params.tool); - const content: ReplyContentDto = { card }; +function buildDirectToolApprovalSlackBlocks(tool: PendingToolApproval): SlackNativeDelivery { + const argumentsBody = formatToolArgumentsBody(tool); + const toolLabel = directToolLabel(tool); - if (params.platform === AgentPlatformEnum.SLACK) { - return { - content, - slackNative: buildToolApprovalSlackBlocks(params.tool), - }; - } + const cardBlock: SlackCardBlock = { + type: 'card', + icon: { + type: 'image', + image_url: resolveSlackMcpIconUrl(undefined), + alt_text: 'Tool', + }, + title: { type: 'mrkdwn', text: 'Tool approval required', verbatim: false }, + subtitle: { type: 'mrkdwn', text: tool.toolName, verbatim: false }, + ...(argumentsBody ? { body: { type: 'mrkdwn', text: argumentsBody, verbatim: false } } : {}), + actions: [ + { + type: 'button', + action_id: buildToolApprovalActionId(DIRECT_TOOL_APPROVAL_ACTION_PREFIX, 'deny', tool.toolUseId), + value: toolLabel, + text: { type: 'plain_text', text: 'Deny', emoji: false }, + }, + { + type: 'button', + style: 'primary', + action_id: buildToolApprovalActionId(DIRECT_TOOL_APPROVAL_ACTION_PREFIX, 'approve', tool.toolUseId), + value: toolLabel, + text: { type: 'plain_text', text: 'Approve once', emoji: false }, + }, + ], + }; - return { content }; + const alwaysAllowBlock: ActionsBlock = { + type: 'actions', + elements: [ + { + type: 'button', + action_id: buildDirectToolApprovalPersistActionId(tool), + value: toolLabel, + text: { type: 'plain_text', text: 'Always allow this tool', emoji: false }, + }, + ], + }; + + return { + blocks: [cardBlock, alwaysAllowBlock], + text: `Approve ${tool.toolName}?`, + }; } -function summariseInput(input: Record): string { - const firstValue = Object.values(input)[0]; - if (firstValue === undefined) return ''; - const text = typeof firstValue === 'string' ? firstValue : JSON.stringify(firstValue); +export function getToolApprovalCard(params: { platform?: string; tool: PendingToolApproval }): ManagedCardDelivery { + const isMcpTool = params.tool.mcpServerName !== undefined; - return text.length > 80 ? `${text.slice(0, 77)}...` : text; + if (isMcpTool) { + const content: ReplyContentDto = { card: buildMcpToolApprovalCard(params.tool) }; + + if (params.platform === AgentPlatformEnum.SLACK) { + return { content, slackNative: buildMcpToolApprovalSlackBlocks(params.tool) }; + } + + return { content }; + } + + const content: ReplyContentDto = { card: buildDirectToolApprovalCard(params.tool) }; + + if (params.platform === AgentPlatformEnum.SLACK) { + return { content, slackNative: buildDirectToolApprovalSlackBlocks(params.tool) }; + } + + return { content }; } diff --git a/apps/api/src/app/agents/managed-runtime/tool-approval/confirm-tool-approval.usecase.ts b/apps/api/src/app/agents/managed-runtime/tool-approval/confirm-tool-approval.usecase.ts index c4d223193f1..99230c75f43 100644 --- a/apps/api/src/app/agents/managed-runtime/tool-approval/confirm-tool-approval.usecase.ts +++ b/apps/api/src/app/agents/managed-runtime/tool-approval/confirm-tool-approval.usecase.ts @@ -1,23 +1,20 @@ import { Injectable } from '@nestjs/common'; import { PinoLogger } from '@novu/application-generic'; -import { AgentMcpServerRepository, McpConnectionRepository, SubscriberRepository } from '@novu/dal'; import { OutboundGateway } from '../../conversation-runtime/egress/outbound.gateway'; import { HandlePlanProgressCommand } from '../../conversation-runtime/reply/handle-plan-progress/handle-plan-progress.command'; import { HandlePlanProgress } from '../../conversation-runtime/reply/handle-plan-progress/handle-plan-progress.usecase'; import { ManagedAgentService } from '../managed-agent.service'; import { type ParsedToolApprovalAction } from './approval-card.builder'; import { ConfirmToolApprovalCommand } from './confirm-tool-approval.command'; -import { mergeToolTrustPatch, resolveTrustForPendingTool } from './tool-trust.helper'; +import { ToolTrustService } from './tool-trust.service'; @Injectable() export class ConfirmToolApproval { constructor( - private readonly subscriberRepository: SubscriberRepository, - private readonly agentMcpServerRepository: AgentMcpServerRepository, - private readonly mcpConnectionRepository: McpConnectionRepository, private readonly managedAgentService: ManagedAgentService, private readonly outboundGateway: OutboundGateway, private readonly handlePlanProgress: HandlePlanProgress, + private readonly toolTrustService: ToolTrustService, private readonly logger: PinoLogger ) { this.logger.setContext(this.constructor.name); @@ -49,49 +46,32 @@ export class ConfirmToolApproval { command: ConfirmToolApprovalCommand, parsed: ParsedToolApprovalAction ): Promise { - if (!parsed.approved || !parsed.persistScope || !command.subscriberId) { + if (!parsed.trust) { return; } - const toolName = parsed.toolName; - const mcpServerName = parsed.mcpServerName; - - if (!toolName || !mcpServerName) { - return; - } - - const subscriber = await this.subscriberRepository.findBySubscriberId(command.environmentId, command.subscriberId); - - if (!subscriber) { - return; - } - - const resolution = await resolveTrustForPendingTool({ - findOAuthEnablementsForAgent: (params) => this.agentMcpServerRepository.findOAuthEnablementsForAgent(params), - findSubscriberConnection: (params) => this.mcpConnectionRepository.findSubscriberConnection(params), - params: { + try { + const persisted = await this.toolTrustService.persist({ environmentId: command.environmentId, organizationId: command.organizationId, - agentId: command.agentId, - subscriberMongoId: subscriber._id, - mcpServerName, - toolName, - }, - }); + agentIdentifier: command.agentIdentifier, + subscriberExternalId: command.subscriberId, + target: parsed.trust, + }); - if (!resolution) { - return; + if (!persisted) { + // No subscriber/agent to attach the preference to: the approval proceeds + // as a one-off, so the card will reappear next time. Logged to make that + // (otherwise silent) miss diagnosable. + this.logger.debug( + { agentIdentifier: command.agentIdentifier, subscriberId: command.subscriberId }, + 'Tool trust preference not persisted (no subscriber/agent); approval is one-off' + ); + } + } catch (err) { + // A failed preference write must not block the approval itself. + this.logger.warn(err, 'Failed to persist tool trust preference; approval will proceed as a one-off'); } - - await this.mcpConnectionRepository.mergeToolTrust({ - connectionId: resolution.connection._id, - environmentId: command.environmentId, - organizationId: command.organizationId, - patch: mergeToolTrustPatch({ - scope: parsed.persistScope, - toolName, - }), - }); } private deleteApprovalCard(command: ConfirmToolApprovalCommand): void { diff --git a/apps/api/src/app/agents/managed-runtime/tool-approval/handle-pending-tool-approvals.usecase.ts b/apps/api/src/app/agents/managed-runtime/tool-approval/handle-pending-tool-approvals.usecase.ts index fcae2ac6e67..3167f0e5be5 100644 --- a/apps/api/src/app/agents/managed-runtime/tool-approval/handle-pending-tool-approvals.usecase.ts +++ b/apps/api/src/app/agents/managed-runtime/tool-approval/handle-pending-tool-approvals.usecase.ts @@ -1,13 +1,7 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; import type { IAgentRuntimeProvider, PendingToolApproval } from '@novu/application-generic'; import { PinoLogger } from '@novu/application-generic'; -import { - AgentMcpServerRepository, - ConversationParticipant, - ConversationRepository, - McpConnectionRepository, - SubscriberRepository, -} from '@novu/dal'; +import { ConversationParticipant, ConversationRepository } from '@novu/dal'; import { NOVU_INTERNAL_TOOLS } from '@novu/shared'; import { AgentSubscriberResolver } from '../../conversation-runtime/conversation/agent-subscriber-resolver.service'; import { HandleAgentReplyCommand } from '../../conversation-runtime/reply/handle-agent-reply/handle-agent-reply.command'; @@ -23,16 +17,14 @@ import { HandleNovuTools } from '../tool-connect/handle-novu-tools.usecase'; import { extractPendingToolApprovals, getToolApprovalCard } from './approval-card.builder'; import { HandlePendingToolApprovalsCommand } from './handle-pending-tool-approvals.command'; import { recoverEmailFromParticipants, recoverSubscriberParticipantId } from './handle-pending-tool-approvals.helpers'; -import { resolveTrustForPendingTool } from './tool-trust.helper'; +import { ToolTrustService } from './tool-trust.service'; @Injectable() export class HandlePendingToolApprovals { constructor( private readonly providerFactory: ManagedAgentProviderFactory, private readonly conversationRepository: ConversationRepository, - private readonly subscriberRepository: SubscriberRepository, - private readonly agentMcpServerRepository: AgentMcpServerRepository, - private readonly mcpConnectionRepository: McpConnectionRepository, + private readonly toolTrustService: ToolTrustService, @Inject(forwardRef(() => ManagedAgentService)) private readonly managedAgentService: ManagedAgentService, private readonly subscriberResolver: AgentSubscriberResolver, @@ -71,11 +63,17 @@ export class HandlePendingToolApprovals { if (externalTools.length === 0) return; - const { trustedTools, needsPromptTools } = await this.partitionByTrust(command, externalTools); + const { autoApprovedTools, pendingApprovalTools } = await this.toolTrustService.partitionByTrust({ + environmentId: command.environmentId, + organizationId: command.organizationId, + agentIdentifier: command.agentIdentifier, + subscriberExternalId: command.subscriberId, + tools: externalTools, + }); - if (trustedTools.length > 0) { + if (autoApprovedTools.length > 0) { try { - await this.autoConfirmTrustedTools(command, trustedTools); + await this.autoConfirmTrustedTools(command, autoApprovedTools); } catch { await this.deliverAutoConfirmFailure(command); @@ -86,14 +84,14 @@ export class HandlePendingToolApprovals { return; } - const nextTool = needsPromptTools[0]; + const nextTool = pendingApprovalTools[0]; if (!nextTool) { return; } - // No trusted tools in this batch — prompt for the first one only (sequential approval). - await this.deliverApprovalCard(command, nextTool, needsPromptTools.length); + // No auto-approved tools in this batch — prompt for the first one only (sequential approval). + await this.deliverApprovalCard(command, nextTool); } private async fetchPendingTools( @@ -148,7 +146,7 @@ export class HandlePendingToolApprovals { sessionId: command.sessionId, toolUseId: tool.toolUseId, }, - 'Auto-confirm for trusted MCP tool failed' + 'Auto-confirm for trusted tool failed' ); captureAgentWarning(err, { component: 'handle-pending-tool-approvals', @@ -197,55 +195,6 @@ export class HandlePendingToolApprovals { } } - private async partitionByTrust( - command: HandlePendingToolApprovalsCommand, - pendingTools: PendingToolApproval[] - ): Promise<{ trustedTools: PendingToolApproval[]; needsPromptTools: PendingToolApproval[] }> { - const conversation = await this.conversationRepository.findOne( - { - _id: command.conversationId, - _environmentId: command.environmentId, - _organizationId: command.organizationId, - }, - ['_agentId'] - ); - - if (!conversation) { - return { trustedTools: [], needsPromptTools: pendingTools }; - } - - const subscriberMongoId = command.subscriberId - ? (await this.subscriberRepository.findBySubscriberId(command.environmentId, command.subscriberId))?._id - : undefined; - - const trustedTools: PendingToolApproval[] = []; - const needsPromptTools: PendingToolApproval[] = []; - - for (const tool of pendingTools) { - const resolution = await resolveTrustForPendingTool({ - findOAuthEnablementsForAgent: (params) => this.agentMcpServerRepository.findOAuthEnablementsForAgent(params), - findSubscriberConnection: (params) => this.mcpConnectionRepository.findSubscriberConnection(params), - params: { - environmentId: command.environmentId, - organizationId: command.organizationId, - agentId: conversation._agentId, - subscriberMongoId, - mcpServerName: tool.mcpServerName, - toolName: tool.toolName, - }, - }); - - if (resolution?.trusted) { - trustedTools.push(tool); - continue; - } - - needsPromptTools.push(tool); - } - - return { trustedTools, needsPromptTools }; - } - private partitionInternalTools(tools: PendingToolApproval[]): { internalTools: PendingToolApproval[]; externalTools: PendingToolApproval[]; @@ -406,13 +355,11 @@ export class HandlePendingToolApprovals { private async deliverApprovalCard( command: HandlePendingToolApprovalsCommand, - tool: PendingToolApproval, - pendingQueueTotal?: number + tool: PendingToolApproval ): Promise { const delivery = getToolApprovalCard({ platform: command.platform, tool, - pendingQueueTotal, }); try { diff --git a/apps/api/src/app/agents/managed-runtime/tool-approval/tool-trust.helper.ts b/apps/api/src/app/agents/managed-runtime/tool-approval/tool-trust.helper.ts deleted file mode 100644 index 637dd78af32..00000000000 --- a/apps/api/src/app/agents/managed-runtime/tool-approval/tool-trust.helper.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { - type AgentMcpServerEntity, - DEFAULT_MCP_TOOL_TRUST_POLICY, - type McpConnectionEntity, - type McpToolTrust, - type McpToolTrustPolicy, -} from '@novu/dal'; -import { MCP_SERVERS } from '@novu/shared'; - -export type ToolTrustPersistScope = 'tool' | 'server'; - -export function resolveToolTrustPolicy(trust: McpToolTrust | undefined, toolName: string): McpToolTrustPolicy { - const toolPolicy = trust?.tools?.[toolName]; - - if (toolPolicy) { - return toolPolicy; - } - - return trust?.serverDefault ?? DEFAULT_MCP_TOOL_TRUST_POLICY; -} - -export function isToolTrusted(trust: McpToolTrust | undefined, toolName: string): boolean { - return resolveToolTrustPolicy(trust, toolName) === 'always_allow'; -} - -export function mergeToolTrustPatch(params: { - scope: ToolTrustPersistScope; - toolName?: string; -}): Partial { - if (params.scope === 'server') { - return { serverDefault: 'always_allow' }; - } - - if (!params.toolName) { - throw new Error('toolName required for tool scope'); - } - - return { tools: { [params.toolName]: 'always_allow' } }; -} - -function matchesMcpServerName(enablement: AgentMcpServerEntity, mcpServerName: string): boolean { - if (enablement.externalProjection?.mcpServerName === mcpServerName) { - return true; - } - - const catalog = MCP_SERVERS.find((entry) => entry.id === enablement.mcpId); - - return catalog?.name === mcpServerName; -} - -export async function resolveTrustForPendingTool(deps: { - findOAuthEnablementsForAgent: (params: { - organizationId: string; - environmentId: string; - agentId: string; - }) => Promise; - findSubscriberConnection: (params: { - organizationId: string; - environmentId: string; - agentMcpServerId: string; - subscriberId: string; - }) => Promise; - params: { - environmentId: string; - organizationId: string; - agentId: string; - subscriberMongoId?: string; - mcpServerName?: string; - toolName: string; - }; -}): Promise<{ connection: McpConnectionEntity; trusted: boolean } | null> { - const { params } = deps; - - if (!params.subscriberMongoId || !params.mcpServerName) { - return null; - } - - const enablements = await deps.findOAuthEnablementsForAgent({ - organizationId: params.organizationId, - environmentId: params.environmentId, - agentId: params.agentId, - }); - const enablement = enablements.find((row) => matchesMcpServerName(row, params.mcpServerName!)); - - if (!enablement) { - return null; - } - - const connection = await deps.findSubscriberConnection({ - organizationId: params.organizationId, - environmentId: params.environmentId, - agentMcpServerId: enablement._id, - subscriberId: params.subscriberMongoId, - }); - - if (!connection) { - return null; - } - - return { - connection, - trusted: isToolTrusted(connection.toolTrust, params.toolName), - }; -} diff --git a/apps/api/src/app/agents/managed-runtime/tool-approval/tool-trust.service.ts b/apps/api/src/app/agents/managed-runtime/tool-approval/tool-trust.service.ts new file mode 100644 index 00000000000..08206ec69c3 --- /dev/null +++ b/apps/api/src/app/agents/managed-runtime/tool-approval/tool-trust.service.ts @@ -0,0 +1,244 @@ +import { Injectable } from '@nestjs/common'; +import { type PendingToolApproval, PinoLogger } from '@novu/application-generic'; +import { + AgentMcpServerRepository, + AgentRepository, + AgentToolTrustRepository, + type AgentToolTrustState, + DEFAULT_TOOL_TRUST_POLICY, + McpConnectionRepository, + SubscriberRepository, + type ToolTrust, + type ToolTrustPolicy, +} from '@novu/dal'; +import { resolveMcpCatalogIdByName } from '@novu/shared'; +import type { ToolTrustTarget } from './approval-card.builder'; + +@Injectable() +export class ToolTrustService { + constructor( + private readonly agentToolTrustRepository: AgentToolTrustRepository, + private readonly agentRepository: AgentRepository, + private readonly subscriberRepository: SubscriberRepository, + private readonly agentMcpServerRepository: AgentMcpServerRepository, + private readonly mcpConnectionRepository: McpConnectionRepository, + private readonly logger: PinoLogger + ) { + this.logger.setContext(this.constructor.name); + } + + /** + * Split a batch of pending tool approvals for one `(agent, subscriber)` into + * the ones the subscriber has pre-approved (`autoApprovedTools`, resolved + * without a card) and the ones that still need an explicit approval + * (`pendingApprovalTools`). With no subscriber (anonymous / platform user) or + * no stored trust, everything stays pending — never auto-approved. + */ + async partitionByTrust(params: { + environmentId: string; + organizationId: string; + agentIdentifier: string; + subscriberExternalId?: string; + tools: PendingToolApproval[]; + }): Promise<{ autoApprovedTools: PendingToolApproval[]; pendingApprovalTools: PendingToolApproval[] }> { + const subscriberMongoId = await this.resolveSubscriberMongoId(params.environmentId, params.subscriberExternalId); + const agentMongoId = await this.resolveAgentMongoId(params.environmentId, params.agentIdentifier); + + if (!subscriberMongoId || !agentMongoId) { + return { autoApprovedTools: [], pendingApprovalTools: [...params.tools] }; + } + + const row = await this.agentToolTrustRepository.findByAgentSubscriber({ + environmentId: params.environmentId, + organizationId: params.organizationId, + agentId: agentMongoId, + subscriberId: subscriberMongoId, + }); + + // Working copy of the stored trust. The legacy backfill below may hydrate it + // in-memory so every tool in this batch resolves through the unified path. + const trust: AgentToolTrustState = row?.trust ?? {}; + + const autoApprovedTools: PendingToolApproval[] = []; + const pendingApprovalTools: PendingToolApproval[] = []; + const legacyMigratedServers = new Set(); + + for (const tool of params.tools) { + const mcpServerName = tool.mcpServerName; + + // First touch of an MCP server with no unified entry: pull its trust over + // from the legacy store once, then resolve the whole batch against it. + if (mcpServerName && !trust.mcp?.[mcpServerName] && !legacyMigratedServers.has(mcpServerName)) { + legacyMigratedServers.add(mcpServerName); + + // Best-effort: a failed backfill must never block delivery of the + // approval card, so on any error we just leave the tool pending. + try { + const bucket = await this.backfillLegacyMcpTrust({ + environmentId: params.environmentId, + organizationId: params.organizationId, + agentId: agentMongoId, + subscriberId: subscriberMongoId, + mcpServerName, + }); + + if (bucket) { + trust.mcp = { ...(trust.mcp ?? {}), [mcpServerName]: bucket }; + } + } catch (error) { + this.logger.warn(error, 'Legacy MCP tool trust backfill failed; leaving tool pending approval'); + } + } + + if (this.isToolTrusted(trust, tool)) { + autoApprovedTools.push(tool); + } else { + pendingApprovalTools.push(tool); + } + } + + return { autoApprovedTools, pendingApprovalTools }; + } + + /** + * TEMPORARY pre-migration backfill: the first time we see an MCP server with + * no entry in the unified `agent_tool_trust` store, copy its trust bucket over + * from the legacy `mcp_connection.toolTrust` store (verbatim) and return it so + * the caller can resolve the current batch against it. After this copy the + * unified store owns the data, so legacy is read at most once per server. + * + * Remove this method (and the two MCP repositories it uses) once the legacy + * `mcp_connection.toolTrust` data has been fully backfilled and the field has + * been dropped from the schema. + */ + private async backfillLegacyMcpTrust(params: { + environmentId: string; + organizationId: string; + agentId: string; + subscriberId: string; + mcpServerName: string; + }): Promise { + const enablement = await this.findLegacyEnablement(params); + if (!enablement) { + return undefined; + } + + const connection = await this.mcpConnectionRepository.findSubscriberConnection({ + organizationId: params.organizationId, + environmentId: params.environmentId, + agentMcpServerId: enablement._id, + subscriberId: params.subscriberId, + }); + + const legacyBucket = connection?.toolTrust; + if (!legacyBucket || (legacyBucket.serverDefault === undefined && !legacyBucket.tools)) { + return undefined; + } + + const bucket: ToolTrust = { serverDefault: legacyBucket.serverDefault, tools: legacyBucket.tools }; + + // Single atomic write so a multi-tool bucket can never be left partially copied. + await this.agentToolTrustRepository.setMcpServerTrust({ + environmentId: params.environmentId, + organizationId: params.organizationId, + agentId: params.agentId, + subscriberId: params.subscriberId, + mcpServerName: params.mcpServerName, + bucket, + }); + + return bucket; + } + + /** + * Match an enabled MCP row for the pending server name. Mirrors the legacy + * resolver: prefer the provider-projected name, then fall back to the catalog + * name — so renamed/projected servers still migrate their trust. + */ + private async findLegacyEnablement(params: { + environmentId: string; + organizationId: string; + agentId: string; + mcpServerName: string; + }) { + const enablements = await this.agentMcpServerRepository.findOAuthEnablementsForAgent({ + organizationId: params.organizationId, + environmentId: params.environmentId, + agentId: params.agentId, + }); + const catalogId = resolveMcpCatalogIdByName(params.mcpServerName); + + return enablements.find( + (row) => + row.externalProjection?.mcpServerName === params.mcpServerName || + (catalogId !== undefined && row.mcpId === catalogId) + ); + } + + /** + * Persist an "always allow" preference for a tool. Returns `false` when there + * is no subscriber to attach the preference to (the click then behaves like + * a one-off approval). + */ + async persist(params: { + environmentId: string; + organizationId: string; + agentIdentifier: string; + subscriberExternalId?: string; + target: ToolTrustTarget; + policy?: ToolTrustPolicy; + }): Promise { + const subscriberMongoId = await this.resolveSubscriberMongoId(params.environmentId, params.subscriberExternalId); + const agentMongoId = await this.resolveAgentMongoId(params.environmentId, params.agentIdentifier); + + if (!subscriberMongoId || !agentMongoId) { + return false; + } + + const { target } = params; + + await this.agentToolTrustRepository.setToolTrust({ + environmentId: params.environmentId, + organizationId: params.organizationId, + agentId: agentMongoId, + subscriberId: subscriberMongoId, + source: target.mcpServerName ? 'mcp' : 'direct', + mcpServerName: target.mcpServerName, + scope: target.scope, + toolName: target.scope === 'tool' ? target.toolName : undefined, + policy: params.policy ?? 'always_allow', + }); + + return true; + } + + /** + * A tool is trusted when its per-tool policy (or the source-wide default) + * resolves to `always_allow`. MCP tools read their server's entry; every + * non-MCP tool reads the shared `direct` entry. + */ + private isToolTrusted(trust: AgentToolTrustState | undefined, tool: PendingToolApproval): boolean { + const toolTrust: ToolTrust | undefined = tool.mcpServerName ? trust?.mcp?.[tool.mcpServerName] : trust?.direct; + const policy = toolTrust?.tools?.[tool.toolName] ?? toolTrust?.serverDefault ?? DEFAULT_TOOL_TRUST_POLICY; + + return policy === 'always_allow'; + } + + private async resolveSubscriberMongoId(environmentId: string, subscriberId?: string): Promise { + if (!subscriberId) { + return undefined; + } + + const subscriber = await this.subscriberRepository.findBySubscriberId(environmentId, subscriberId); + + return subscriber?._id; + } + + private async resolveAgentMongoId(environmentId: string, agentIdentifier: string): Promise { + const agent = await this.agentRepository.findOne({ identifier: agentIdentifier, _environmentId: environmentId }, [ + '_id', + ]); + + return agent?._id; + } +} diff --git a/libs/dal/src/index.ts b/libs/dal/src/index.ts index 6519946c682..ca326d6acac 100644 --- a/libs/dal/src/index.ts +++ b/libs/dal/src/index.ts @@ -2,6 +2,7 @@ export * from './dal.service'; export * from './repositories/agent'; export * from './repositories/agent-integration'; export * from './repositories/agent-mcp-server'; +export * from './repositories/agent-tool-trust'; export * from './repositories/ai-chat'; export * from './repositories/base-repository'; export * from './repositories/base-repository-v2'; diff --git a/libs/dal/src/repositories/agent-tool-trust/agent-tool-trust.entity.ts b/libs/dal/src/repositories/agent-tool-trust/agent-tool-trust.entity.ts new file mode 100644 index 00000000000..8821bf4be71 --- /dev/null +++ b/libs/dal/src/repositories/agent-tool-trust/agent-tool-trust.entity.ts @@ -0,0 +1,45 @@ +import type { ChangePropsValueType } from '../../types/helpers'; +import type { EnvironmentId } from '../environment'; +import type { OrganizationId } from '../organization'; +import { SubscriberId } from '../subscriber'; + +export type ToolTrustPolicy = 'always_ask' | 'always_allow'; + +export const DEFAULT_TOOL_TRUST_POLICY: ToolTrustPolicy = 'always_ask'; + +export type ToolTrust = { + /** Applies to every tool from this source unless a per-tool override exists. */ + serverDefault?: ToolTrustPolicy; + /** Per-tool overrides keyed by tool name (e.g. "list_issues"). */ + tools?: Record; +}; + +export interface AgentToolTrustState { + /** Per-MCP-server trust, keyed by `mcpServerName`. */ + mcp?: Record; + /** Catch-all trust bucket for non-MCP (directly-invoked) tools. */ + direct?: ToolTrust; +} + +export class AgentToolTrustEntity { + _id: string; + + _organizationId: OrganizationId; + + _environmentId: EnvironmentId; + + _agentId: string; + + _subscriberId: SubscriberId; + + trust: AgentToolTrustState; + + createdAt: string; + + updatedAt: string; +} + +export type AgentToolTrustDBModel = ChangePropsValueType< + AgentToolTrustEntity, + '_agentId' | '_environmentId' | '_organizationId' | '_subscriberId' +>; diff --git a/libs/dal/src/repositories/agent-tool-trust/agent-tool-trust.repository.ts b/libs/dal/src/repositories/agent-tool-trust/agent-tool-trust.repository.ts new file mode 100644 index 00000000000..eab0ea59fda --- /dev/null +++ b/libs/dal/src/repositories/agent-tool-trust/agent-tool-trust.repository.ts @@ -0,0 +1,151 @@ +import type { EnforceEnvOrOrgIds } from '../../types'; +import { BaseRepositoryV2 } from '../base-repository-v2'; +import { + AgentToolTrustDBModel, + AgentToolTrustEntity, + type ToolTrust, + type ToolTrustPolicy, +} from './agent-tool-trust.entity'; +import { AgentToolTrust } from './agent-tool-trust.schema'; + +export type ToolTrustSource = 'mcp' | 'direct'; + +export type ToolTrustPersistScope = 'tool' | 'server'; + +export class AgentToolTrustRepository extends BaseRepositoryV2< + AgentToolTrustDBModel, + AgentToolTrustEntity, + EnforceEnvOrOrgIds +> { + constructor() { + super(AgentToolTrust, AgentToolTrustEntity); + } + + /** + * The trust row for a `(agent, subscriber)`. Returns `null` before the + * subscriber has trusted anything on this agent. + */ + async findByAgentSubscriber({ + organizationId, + environmentId, + agentId, + subscriberId, + }: { + organizationId: string; + environmentId: string; + agentId: string; + subscriberId: string; + }): Promise { + return this.findOne( + { + _environmentId: environmentId, + _organizationId: organizationId, + _agentId: agentId, + _subscriberId: subscriberId, + }, + '*' + ); + } + + /** + * Persist an "always allow" preference. Upserts the single + * `(agent, subscriber)` row and sets exactly one trust path: + * + * - `server` scope → `trust..serverDefault` + * - `tool` scope → `trust..tools.` + * + * For `mcp` source the bucket is nested under the `mcpServerName`. + */ + async setToolTrust(params: { + organizationId: string; + environmentId: string; + agentId: string; + subscriberId: string; + source: ToolTrustSource; + scope: ToolTrustPersistScope; + /** Required when `source === 'mcp'`. */ + mcpServerName?: string; + /** Required when `scope === 'tool'`. */ + toolName?: string; + policy: ToolTrustPolicy; + }): Promise { + const path = this.buildTrustPath(params); + + await this.update( + { + _environmentId: params.environmentId, + _organizationId: params.organizationId, + _agentId: params.agentId, + _subscriberId: params.subscriberId, + }, + { $set: { [path]: params.policy } }, + { upsert: true } + ); + } + + /** + * Replace an MCP server's entire trust bucket in a single atomic update. + * Used by the legacy backfill so a multi-tool copy can never be left partial. + */ + async setMcpServerTrust(params: { + organizationId: string; + environmentId: string; + agentId: string; + subscriberId: string; + mcpServerName: string; + bucket: ToolTrust; + }): Promise { + assertSafeToolTrustKeySegment(params.mcpServerName); + for (const toolName of Object.keys(params.bucket.tools ?? {})) { + assertSafeToolTrustKeySegment(toolName); + } + + await this.update( + { + _environmentId: params.environmentId, + _organizationId: params.organizationId, + _agentId: params.agentId, + _subscriberId: params.subscriberId, + }, + { $set: { [`trust.mcp.${params.mcpServerName}`]: params.bucket } }, + { upsert: true } + ); + } + + private buildTrustPath(params: { + source: ToolTrustSource; + scope: ToolTrustPersistScope; + mcpServerName?: string; + toolName?: string; + }): string { + let base: string; + + if (params.source === 'mcp') { + if (!params.mcpServerName) { + throw new Error('mcpServerName required for mcp tool trust'); + } + assertSafeToolTrustKeySegment(params.mcpServerName); + base = `trust.mcp.${params.mcpServerName}`; + } else { + base = 'trust.direct'; + } + + if (params.scope === 'server') { + return `${base}.serverDefault`; + } + + if (!params.toolName) { + throw new Error('toolName required for tool scope'); + } + assertSafeToolTrustKeySegment(params.toolName); + + return `${base}.tools.${params.toolName}`; + } +} + +function assertSafeToolTrustKeySegment(name: string): void { + // Interpolated into a dotted Mongo update path; `.` / `$` / NUL would corrupt it. + if (name.includes('.') || name.includes('$') || name.includes('\0')) { + throw new Error(`Invalid tool trust key segment: ${name}`); + } +} diff --git a/libs/dal/src/repositories/agent-tool-trust/agent-tool-trust.schema.ts b/libs/dal/src/repositories/agent-tool-trust/agent-tool-trust.schema.ts new file mode 100644 index 00000000000..75dde5cf338 --- /dev/null +++ b/libs/dal/src/repositories/agent-tool-trust/agent-tool-trust.schema.ts @@ -0,0 +1,79 @@ +import mongoose, { Schema } from 'mongoose'; + +import { schemaOptions } from '../schema-default.options'; +import { AgentToolTrustDBModel } from './agent-tool-trust.entity'; + +const toolTrustSchema = new Schema( + { + serverDefault: { + type: Schema.Types.String, + required: false, + enum: ['always_ask', 'always_allow'], + }, + tools: { + type: Schema.Types.Map, + of: { + type: Schema.Types.String, + enum: ['always_ask', 'always_allow'], + }, + required: false, + }, + }, + { _id: false } +); + +const trustSchema = new Schema( + { + // Keyed by `mcpServerName`; one trust bucket per connected MCP server. + mcp: { + type: Schema.Types.Map, + of: toolTrustSchema, + required: false, + }, + // Catch-all bucket for non-MCP (directly-invoked) tools: provider built-in + // toolset, user-defined custom tools, and any future non-MCP tool type. + direct: { + type: toolTrustSchema, + required: false, + }, + }, + { _id: false } +); + +const agentToolTrustSchema = new Schema( + { + _organizationId: { + type: Schema.Types.ObjectId, + ref: 'Organization', + required: true, + }, + _environmentId: { + type: Schema.Types.ObjectId, + ref: 'Environment', + required: true, + }, + _agentId: { + type: Schema.Types.ObjectId, + ref: 'Agent', + required: true, + }, + _subscriberId: { + type: Schema.Types.ObjectId, + ref: 'Subscriber', + required: true, + }, + trust: { + type: trustSchema, + required: true, + default: {}, + }, + }, + schemaOptions +); + +// Single trust row per (env, agent, subscriber) — the source of truth. +agentToolTrustSchema.index({ _environmentId: 1, _agentId: 1, _subscriberId: 1 }, { unique: true }); + +export const AgentToolTrust = + (mongoose.models.AgentToolTrust as mongoose.Model) || + mongoose.model('AgentToolTrust', agentToolTrustSchema); diff --git a/libs/dal/src/repositories/agent-tool-trust/index.ts b/libs/dal/src/repositories/agent-tool-trust/index.ts new file mode 100644 index 00000000000..50b3b5099a8 --- /dev/null +++ b/libs/dal/src/repositories/agent-tool-trust/index.ts @@ -0,0 +1,3 @@ +export * from './agent-tool-trust.entity'; +export * from './agent-tool-trust.repository'; +export * from './agent-tool-trust.schema'; diff --git a/packages/chat-adapter/.gitignore b/packages/chat-adapter/.gitignore new file mode 100644 index 00000000000..cc1b7f16421 --- /dev/null +++ b/packages/chat-adapter/.gitignore @@ -0,0 +1 @@ +tsconfig.tsbuildinfo diff --git a/packages/chat-adapter/README.md b/packages/chat-adapter/README.md new file mode 100644 index 00000000000..85a7540889f --- /dev/null +++ b/packages/chat-adapter/README.md @@ -0,0 +1,129 @@ +# @novu/chat-sdk-adapter + +A [Chat SDK](https://www.npmjs.com/package/chat) platform adapter that exposes **all of Novu's +normalized chat channels — Slack, WhatsApp, Microsoft Teams, Telegram, and Email — as a single +platform**. Novu does the per-channel normalization (one `Conversation` + `Subscriber` + history) +and calls your bridge; your Chat SDK app is the brain. One handler set serves every channel with no +per-channel code. + +``` +End-user channels ──platform webhooks──▶ NOVU (normalize) ──POST AgentBridgeRequest (HMAC)──▶ + your Chat SDK app (@novu/chat-sdk-adapter) ──AgentReplyPayload → POST /v1/agents/:id/reply──▶ NOVU ──▶ channel +``` + +## Example app + +For a complete Next.js boilerplate — webhook route, bridge registration, setup UI, and handler +examples — see **[novu-chat-sdk-example](https://github.com/novuhq/novu-chat-sdk-example)**. + +## Install + +```bash +npm install @novu/chat-sdk-adapter chat @chat-adapter/state-memory +``` + +`chat` is a peer dependency (`>=4.30.0`). `react` is an optional peer (only needed for JSX cards). +A `StateAdapter` is required by the Chat SDK — use the official `@chat-adapter/state-memory` +for local/single-instance, or a shared adapter (`@chat-adapter/state-redis`, +`@chat-adapter/state-ioredis`, `@chat-adapter/state-pg`) for production. + +## Usage + +```ts +import { Chat } from 'chat'; +import { createMemoryState } from '@chat-adapter/state-memory'; +import { createNovuAdapter, getNovuContext } from '@novu/chat-sdk-adapter'; + +const novu = createNovuAdapter({ + apiKey: process.env.NOVU_SECRET_KEY!, // Authorization for reply POSTs + agentIdentifier: 'support-agent', + bridgeSecret: process.env.NOVU_SECRET_KEY!, // verifies inbound HMAC + // apiBaseUrl: 'https://eu.api.novu.co', // defaults to https://api.novu.co + // bridgeUrl: 'https://my-app.com/api/novu', // optional boot-time bridge registration +}); + +// Or rely on env vars — NOVU_SECRET_KEY, NOVU_AGENT_IDENTIFIER, +// NOVU_API_BASE_URL, NOVU_BRIDGE_URL (explicit config wins): +// const novu = createNovuAdapter(); + +const chat = new Chat({ + userName: 'support', + adapters: { novu }, + state: createMemoryState(), // single-instance only; use Redis/PG for horizontal scale +}); + +chat.onNewMention(async (thread, message) => { + if (thread.isDM) { + await thread.post(`Hi (DM)! You said: ${message.text}`); + } else { + await thread.post(`Hi! You said: ${message.text}`); + } +}); + +chat.onSubscribedMessage(async (thread, message) => { + await thread.post(`echo: ${message.text}`); + + // Opt-in, Novu-only capabilities: + const ctx = getNovuContext(thread); + + const subscriber = await ctx.getSubscriber(); + const history = await ctx.getHistory(); + const ticketId = await ctx.getMetadata('ticketId'); + + if (subscriber?.data?.plan === 'enterprise') { + await thread.post('Priority support enabled.'); + } + + if (ctx.platform === 'whatsapp') { + await ctx.trigger('escalation-email', { payload: { text: message.text } }); + } + + // Markdown with a file attachment: + await thread.post({ + markdown: 'See attached report', + files: [{ filename: 'report.txt', data: Buffer.from('...'), mimeType: 'text/plain' }], + }); + + // Portable SDK-native identity (id, name, email, avatarUrl): + const user = await novu.getUser(message.author.userId); +}); + +await chat.initialize(); +``` + +Wire the webhook route to `novu.handleWebhook(request)` (any Web `Request`/`Response` runtime — +Next.js route handlers, Hono, etc.). The adapter verifies the `novu-signature` HMAC over the raw +body; you can also call `verifyNovuSignature()` directly if you need custom middleware. + +## Behavior & v1 scope + +- **In:** messages, button actions, reactions, full Novu history, subscriber identity, platform + awareness, dedup (per `deliveryId`, committed after successful dispatch). +- **Subscriber:** portable identity rides each message's `author`; `adapter.getUser(userId)` maps + the subscriber to `UserInfo` (id/name/email/avatar); the full profile (`phone`, `locale`, + custom `data`) is available via `getNovuContext(thread).getSubscriber()`. +- **Conversation & history:** `getConversation()` for status/metadata; `getHistory()` for the + canonical Novu transcript (best for LLM context); `getMetadata(key)` to read conversation metadata; + `getEmailContext()` on email threads. +- **Out:** markdown, cards, **files** (via postable `files`/`attachments`), edits (in-place), + reaction adds, edit-based streaming (via the chat package's built-in cadence), plus opt-in + `getNovuContext().trigger`, `setMetadata`, `deleteMetadata`, `clearMetadata`, and `resolve`. +- **Routing (recommended):** do **not** register `onDirectMessage` — use `onNewMention` for the + first message (`thread.isDM` for DM vs channel) and `onSubscribedMessage` for all follow-ups. + The adapter pre-subscribes when `messageCount > 1` (Novu history always includes the current + message, so history length is not used). If you register `onDirectMessage`, Chat SDK sends + **every** DM there and `onSubscribedMessage` never runs for DMs. +- **Security:** the inbound HMAC (`novu-signature`) is verified over the raw body; the reply URL is + **derived from your config** and the request's `replyUrl` is ignored, so a forged request can + never exfiltrate your `apiKey`. +- **Not implemented in v1:** `deleteMessage`, modals, outbound-initiated DMs (`openDM`), code-driven + channel provisioning, Novu-side turn serialization. + +## State + +This adapter does not ship its own state layer — it relies on the Chat SDK's standard +`StateAdapter`. Use the official memory adapter `@chat-adapter/state-memory` +(`createMemoryState()`), which is in-process and safe for a single instance. For +horizontally-scaled or serverless bridges with more than one warm instance, pass a shared +state adapter (`@chat-adapter/state-redis`, `@chat-adapter/state-ioredis`, or +`@chat-adapter/state-pg`) to `new Chat({ state })` so locks and dedup are correct. diff --git a/packages/chat-adapter/package.json b/packages/chat-adapter/package.json new file mode 100644 index 00000000000..84b58944aad --- /dev/null +++ b/packages/chat-adapter/package.json @@ -0,0 +1,75 @@ +{ + "name": "@novu/chat-sdk-adapter", + "version": "0.0.2", + "type": "module", + "description": "Novu adapter for the Chat SDK — expose all of Novu's normalized chat channels (Slack, WhatsApp, Teams, Telegram, Email) as a single Chat SDK platform adapter", + "license": "MIT", + "homepage": "https://github.com/novuhq/novu/tree/next/packages/chat-adapter#readme", + "repository": { + "type": "git", + "url": "https://github.com/novuhq/novu.git", + "directory": "packages/chat-adapter" + }, + "bugs": { + "url": "https://github.com/novuhq/novu/issues" + }, + "keywords": [ + "chat-sdk", + "chat-adapter", + "novu", + "slack", + "whatsapp", + "telegram", + "teams", + "agent" + ], + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist/" + ], + "scripts": { + "prepublishOnly": "pnpm build", + "prebuild": "rimraf dist tsconfig.tsbuildinfo", + "build": "tsc -p tsconfig.json", + "watch:build": "tsc -p tsconfig.json -w", + "test": "vitest run", + "test:watch": "vitest", + "check": "biome check .", + "check:fix": "biome check --write ." + }, + "dependencies": { + "@chat-adapter/shared": "4.30.0" + }, + "peerDependencies": { + "chat": "^4.30.0", + "react": ">=18.0.0 || >=19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + }, + "devDependencies": { + "@chat-adapter/state-memory": "4.30.0", + "@types/react": "^19.0.0", + "rimraf": "~3.0.2", + "typescript": "5.6.2", + "vitest": "^1.2.1" + }, + "publishConfig": { + "access": "public" + }, + "nx": { + "tags": [ + "type:package" + ] + } +} diff --git a/packages/chat-adapter/project.json b/packages/chat-adapter/project.json new file mode 100644 index 00000000000..08c18718dfc --- /dev/null +++ b/packages/chat-adapter/project.json @@ -0,0 +1,28 @@ +{ + "name": "@novu/chat-sdk-adapter", + "sourceRoot": "packages/chat-adapter/src", + "projectType": "library", + "targets": { + "build": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm --filter @novu/chat-sdk-adapter run build", + "cwd": "{workspaceRoot}" + } + }, + "test": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm --filter @novu/chat-sdk-adapter run test", + "cwd": "{workspaceRoot}" + } + }, + "lint": { + "executor": "nx:run-commands", + "options": { + "command": "npx biome lint packages/chat-adapter" + } + } + }, + "tags": ["type:package"] +} diff --git a/packages/chat-adapter/src/adapter.integration.spec.ts b/packages/chat-adapter/src/adapter.integration.spec.ts new file mode 100644 index 00000000000..ee701ba864a --- /dev/null +++ b/packages/chat-adapter/src/adapter.integration.spec.ts @@ -0,0 +1,627 @@ +import { createHmac } from 'node:crypto'; +import { createMemoryState } from '@chat-adapter/state-memory'; +import { Actions, Button, Card, CardText, Chat } from 'chat'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NovuAdapterImpl } from './adapter.js'; +import { createNovuAdapter, getNovuContext } from './index.js'; +import { encodeThreadId } from './thread-id.js'; +import type { AgentBridgeRequest, AgentSubscriber, NovuRawMessage } from './types.js'; + +const BRIDGE_SECRET = 'bridge-secret'; +const API_KEY = 'api-key'; + +function sign(body: string, secret = BRIDGE_SECRET): string { + const ts = Date.now(); + const hmac = createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex'); + + return `t=${ts},v1=${hmac}`; +} + +function bridgeRequest(overrides: Partial = {}): AgentBridgeRequest { + return { + version: 1, + timestamp: new Date().toISOString(), + deliveryId: `d-${Math.random()}`, + event: 'onMessage', + agentId: 'support-agent', + replyUrl: 'https://attacker.example.com/steal', + conversationId: 'conv-1', + integrationIdentifier: 'slack-prod', + action: null, + message: { + text: 'hello', + platformMessageId: 'pm-1', + author: { + userId: 'u1', + userName: 'alice', + fullName: 'Alice', + isBot: false, + }, + timestamp: new Date().toISOString(), + }, + reaction: null, + conversation: { + identifier: 'conv-1', + status: 'open', + metadata: {}, + messageCount: 2, + createdAt: new Date().toISOString(), + lastActivityAt: new Date().toISOString(), + }, + subscriber: { subscriberId: 'sub-1', firstName: 'Alice' }, + history: [ + { + role: 'user', + type: 'text', + content: 'earlier', + createdAt: new Date().toISOString(), + }, + ], + platform: 'slack', + platformContext: { threadId: 'pm-1', channelId: 'C1', isDM: false }, + ...overrides, + }; +} + +async function deliver(adapter: ReturnType, req: AgentBridgeRequest): Promise { + const body = JSON.stringify(req); + const request = new Request('https://bridge.example.com/api/novu', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'novu-signature': sign(body), + }, + body, + }); + + return adapter.handleWebhook(request); +} + +describe('Novu adapter end-to-end', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn( + async () => new Response(JSON.stringify({ messageId: 'm-1', platformThreadId: 't-1' }), { status: 200 }) + ); + }); + + function buildChat() { + const adapter = createNovuAdapter({ + apiKey: API_KEY, + agentIdentifier: 'support-agent', + bridgeSecret: BRIDGE_SECRET, + fetch: fetchMock as unknown as typeof fetch, + }); + const chat = new Chat({ + userName: 'support', + adapters: { novu: adapter }, + state: createMemoryState(), + }); + + return { adapter, chat }; + } + + it('routes an ongoing conversation to onSubscribedMessage and replies via the derived URL', async () => { + const { adapter, chat } = buildChat(); + const seen: string[] = []; + chat.onSubscribedMessage(async (thread, message) => { + seen.push(message.text); + await thread.post(`echo: ${message.text}`); + }); + await chat.initialize(); + + const res = await deliver(adapter, bridgeRequest()); + expect(res.status).toBe(200); + expect(seen).toEqual(['hello']); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]!; + // Reply went to the derived URL, NOT the attacker-controlled replyUrl in the request. + expect(url).toBe('https://api.novu.co/v1/agents/support-agent/reply'); + expect((init.headers as Record).authorization).toBe(`ApiKey ${API_KEY}`); + expect(JSON.parse(init.body as string)).toMatchObject({ + conversationId: 'conv-1', + integrationIdentifier: 'slack-prod', + reply: { markdown: 'echo: hello' }, + }); + }); + + it('routes a brand-new channel conversation to onNewMention', async () => { + const { adapter, chat } = buildChat(); + const mentions: string[] = []; + chat.onNewMention(async (_thread, message) => { + mentions.push(message.text); + }); + chat.onSubscribedMessage(async () => { + throw new Error('should not be subscribed on first message'); + }); + await chat.initialize(); + + await deliver( + adapter, + bridgeRequest({ + conversation: { ...bridgeRequest().conversation, messageCount: 1 }, + history: [], + }) + ); + + expect(mentions).toEqual(['hello']); + }); + + it('routes first channel mention to onNewMention when history includes the current message', async () => { + const { adapter, chat } = buildChat(); + const mentions: string[] = []; + chat.onNewMention(async (_thread, message) => { + mentions.push(message.text); + }); + chat.onSubscribedMessage(async () => { + throw new Error('first mention should not route to onSubscribedMessage'); + }); + await chat.initialize(); + + await deliver( + adapter, + bridgeRequest({ + conversation: { ...bridgeRequest().conversation, messageCount: 1 }, + history: [ + { + role: 'user', + type: 'text', + content: 'hello', + createdAt: new Date().toISOString(), + }, + ], + }) + ); + + expect(mentions).toEqual(['hello']); + }); + + it('routes an ongoing DM conversation to onSubscribedMessage (not onDirectMessage)', async () => { + const { adapter, chat } = buildChat(); + const subscribed: string[] = []; + chat.onNewMention(async () => { + throw new Error('ongoing DM should not route to onNewMention'); + }); + chat.onSubscribedMessage(async (_thread, message) => { + subscribed.push(message.text); + }); + await chat.initialize(); + + await deliver( + adapter, + bridgeRequest({ + platformContext: { + threadId: 'dm-thread', + channelId: 'dm-channel', + isDM: true, + }, + conversation: { ...bridgeRequest().conversation, messageCount: 3 }, + }) + ); + + expect(subscribed).toEqual(['hello']); + }); + + it('rejects an invalid signature with 401 and does not dispatch', async () => { + const { adapter, chat } = buildChat(); + const handler = vi.fn(); + chat.onSubscribedMessage(handler); + await chat.initialize(); + + const body = JSON.stringify(bridgeRequest()); + const request = new Request('https://bridge.example.com/api/novu', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'novu-signature': sign(body, 'wrong-secret'), + }, + body, + }); + const res = await adapter.handleWebhook(request); + + expect(res.status).toBe(401); + expect(handler).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('normalizes a chat-sdk Card posted by a handler into reply.card', async () => { + const { adapter, chat } = buildChat(); + chat.onSubscribedMessage(async (thread) => { + await thread.post( + Card({ + title: 'Card title', + subtitle: 'Card subtitle', + children: [ + CardText('Hello from a card'), + Actions([ + Button({ + id: 'approve', + label: 'Approve', + style: 'primary', + value: 'yes', + }), + ]), + ], + }) + ); + }); + await chat.initialize(); + + await deliver(adapter, bridgeRequest()); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, init] = fetchMock.mock.calls[0]; + const payload = JSON.parse(init.body as string); + expect(payload.reply.markdown).toBeUndefined(); + expect(payload.reply.card).toMatchObject({ + type: 'card', + title: 'Card title', + subtitle: 'Card subtitle', + }); + expect(payload.reply.card.children).toEqual( + expect.arrayContaining([expect.objectContaining({ type: 'text', content: 'Hello from a card' })]) + ); + }); + + it('exposes the full subscriber via getNovuContext(thread).getSubscriber()', async () => { + const { adapter, chat } = buildChat(); + const richSubscriber: AgentSubscriber = { + subscriberId: 'sub-1', + firstName: 'Alice', + lastName: 'Smith', + email: 'alice@example.com', + phone: '+15550001111', + avatar: 'https://cdn.example.com/alice.png', + locale: 'en-US', + data: { plan: 'enterprise' }, + }; + let captured: AgentSubscriber | null = null; + chat.onSubscribedMessage(async (thread) => { + captured = await getNovuContext(thread).getSubscriber(); + }); + await chat.initialize(); + + await deliver(adapter, bridgeRequest({ subscriber: richSubscriber })); + + expect(captured).toEqual(richSubscriber); + }); + + it('presents the subscriber as the message author so getUser(author.userId) resolves', async () => { + const { adapter, chat } = buildChat(); + let authorUserId = ''; + let resolvedFullName: string | undefined; + chat.onSubscribedMessage(async (_thread, message) => { + authorUserId = message.author.userId; + // The platform-native author is still available on the raw escape hatch. + expect((message.raw as NovuRawMessage).author.userId).toBe('u1'); + const user = await adapter.getUser?.(message.author.userId); + resolvedFullName = user?.fullName; + }); + await chat.initialize(); + + await deliver( + adapter, + bridgeRequest({ + message: { + text: 'hello', + platformMessageId: 'pm-1', + author: { + userId: 'u1', + userName: 'alice', + fullName: 'Alice', + isBot: false, + }, + timestamp: new Date().toISOString(), + }, + subscriber: { + subscriberId: 'sub-1', + firstName: 'Alice', + lastName: 'Smith', + }, + }) + ); + + expect(authorUserId).toBe('sub-1'); + expect(resolvedFullName).toBe('Alice Smith'); + }); + + it('resolves the subscriber as portable UserInfo via getUser(subscriberId)', async () => { + const { adapter, chat } = buildChat(); + chat.onSubscribedMessage(async () => {}); + await chat.initialize(); + + await deliver( + adapter, + bridgeRequest({ + subscriber: { + subscriberId: 'sub-1', + firstName: 'Alice', + lastName: 'Smith', + email: 'alice@example.com', + avatar: 'https://cdn.example.com/alice.png', + }, + }) + ); + + expect(await adapter.getUser?.('sub-1')).toEqual({ + userId: 'sub-1', + userName: 'sub-1', + fullName: 'Alice Smith', + email: 'alice@example.com', + avatarUrl: 'https://cdn.example.com/alice.png', + isBot: false, + }); + expect(await adapter.getUser?.('unknown')).toBeNull(); + }); + + it('exposes conversation, history, metadata, and email context via getNovuContext', async () => { + const { adapter, chat } = buildChat(); + let ctx: ReturnType | null = null; + chat.onSubscribedMessage(async (thread) => { + ctx = getNovuContext(thread); + }); + await chat.initialize(); + + await deliver( + adapter, + bridgeRequest({ + platform: 'email', + conversation: { + identifier: 'conv-1', + status: 'open', + metadata: { ticketId: 'T-42' }, + messageCount: 2, + createdAt: '2026-01-01T00:00:00.000Z', + lastActivityAt: '2026-01-02T00:00:00.000Z', + }, + history: [ + { + role: 'user', + type: 'text', + content: 'earlier with attachment', + richContent: { + attachments: [ + { + type: 'image', + url: 'https://cdn.example.com/a.png', + name: 'a.png', + }, + ], + }, + createdAt: '2026-01-01T00:00:00.000Z', + }, + ], + platformContext: { + threadId: 'email-thread', + channelId: 'email-channel', + isDM: false, + email: { + domain: { id: 'dom-1', name: 'support.example.com' }, + route: { address: 'help@support.example.com' }, + rootMessageId: '', + }, + }, + }) + ); + + expect(ctx).not.toBeNull(); + const novu = ctx!; + + expect(await novu.getConversation()).toMatchObject({ + identifier: 'conv-1', + status: 'open', + messageCount: 2, + metadata: { ticketId: 'T-42' }, + }); + expect(await novu.getMetadata('ticketId')).toBe('T-42'); + expect(await novu.getHistory()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + role: 'user', + content: 'earlier with attachment', + richContent: expect.objectContaining({ + attachments: expect.arrayContaining([expect.objectContaining({ url: 'https://cdn.example.com/a.png' })]), + }), + }), + ]) + ); + expect(await novu.getEmailContext()).toMatchObject({ + domain: { id: 'dom-1', name: 'support.example.com' }, + route: { address: 'help@support.example.com' }, + rootMessageId: '', + }); + + await novu.clearMetadata(); + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: expect.stringContaining('"action":"clear"'), + }) + ); + expect(await novu.getMetadata('ticketId')).toBeUndefined(); + expect((await novu.getConversation())?.metadata).toEqual({}); + }); + + it('updates metadata snapshot optimistically after setMetadata in the same handler turn', async () => { + const { adapter, chat } = buildChat(); + let ctx: ReturnType | null = null; + chat.onSubscribedMessage(async (thread) => { + ctx = getNovuContext(thread); + await ctx.setMetadata('ticketId', 'T-99'); + expect(await ctx.getMetadata('ticketId')).toBe('T-99'); + expect((await ctx.getConversation())?.metadata.ticketId).toBe('T-99'); + }); + await chat.initialize(); + + await deliver( + adapter, + bridgeRequest({ + conversation: { + identifier: 'conv-1', + status: 'open', + metadata: { ticketId: 'T-42' }, + messageCount: 2, + createdAt: new Date().toISOString(), + lastActivityAt: new Date().toISOString(), + }, + }) + ); + + expect(ctx).not.toBeNull(); + }); + + it('marks conversation resolved in snapshot after resolve()', async () => { + const { adapter, chat } = buildChat(); + let ctx: ReturnType | null = null; + chat.onSubscribedMessage(async (thread) => { + ctx = getNovuContext(thread); + await ctx.resolve('done'); + expect((await ctx.getConversation())?.status).toBe('resolved'); + }); + await chat.initialize(); + + await deliver(adapter, bridgeRequest()); + expect(ctx).not.toBeNull(); + }); + + it('preserves Novu history fields on fetchMessages', async () => { + const { adapter, chat } = buildChat(); + chat.onSubscribedMessage(async () => {}); + await chat.initialize(); + + await deliver( + adapter, + bridgeRequest({ + history: [ + { + role: 'assistant', + type: 'card', + content: 'Card fallback text', + richContent: { + card: { type: 'card', title: 'Saved card', children: [] }, + }, + senderName: 'Agent', + createdAt: '2026-01-01T00:00:00.000Z', + }, + ], + }) + ); + + const threadId = encodeThreadId({ + platform: 'slack', + integrationIdentifier: 'slack-prod', + conversationId: 'conv-1', + isDM: false, + }); + const { messages } = await adapter.fetchMessages(threadId); + const historyMsg = messages[0]!; + + expect(historyMsg.text).toBe('Card fallback text'); + expect((historyMsg.raw as NovuRawMessage).history).toMatchObject({ + role: 'assistant', + type: 'card', + richContent: expect.objectContaining({ + card: expect.objectContaining({ title: 'Saved card' }), + }), + }); + }); + + it('normalizes outbound files on markdown replies into reply.files', async () => { + const { adapter, chat } = buildChat(); + chat.onSubscribedMessage(async (thread) => { + await thread.post({ + markdown: 'See attached', + files: [ + { + filename: 'note.txt', + data: Buffer.from('hello'), + mimeType: 'text/plain', + }, + ], + }); + }); + await chat.initialize(); + + await deliver(adapter, bridgeRequest()); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, init] = fetchMock.mock.calls[0]!; + const payload = JSON.parse(init.body as string); + expect(payload.reply.markdown).toBe('See attached'); + expect(payload.reply.files).toEqual([ + expect.objectContaining({ + filename: 'note.txt', + mimeType: 'text/plain', + data: Buffer.from('hello').toString('base64'), + }), + ]); + }); + + it('dedupes a replayed deliveryId (same delivery processed once)', async () => { + const { adapter, chat } = buildChat(); + const handler = vi.fn(); + chat.onSubscribedMessage(handler); + await chat.initialize(); + + const req = bridgeRequest({ deliveryId: 'fixed-delivery' }); + await deliver(adapter, req); + await deliver(adapter, req); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('allows retry after transient dispatch failure (does not permanently dedupe)', async () => { + const { adapter, chat } = buildChat(); + const handler = vi.fn(); + chat.onSubscribedMessage(handler); + await chat.initialize(); + + const req = bridgeRequest({ deliveryId: 'retry-delivery' }); + const cacheSnapshot = vi.spyOn(NovuAdapterImpl.prototype, 'cacheSnapshot'); + cacheSnapshot.mockRejectedValueOnce(new Error('transient cache failure')); + + await expect(deliver(adapter, req)).rejects.toThrow('transient cache failure'); + await deliver(adapter, req); + + expect(handler).toHaveBeenCalledTimes(1); + cacheSnapshot.mockRestore(); + }); + + it('applies fetchMessages limit and pagination cursor', async () => { + const { adapter, chat } = buildChat(); + chat.onSubscribedMessage(async () => {}); + await chat.initialize(); + + await deliver( + adapter, + bridgeRequest({ + history: Array.from({ length: 5 }, (_, index) => ({ + role: 'user' as const, + type: 'text' as const, + content: `msg-${index}`, + createdAt: `2026-01-01T00:00:0${index}.000Z`, + })), + }) + ); + + const threadId = encodeThreadId({ + platform: 'slack', + integrationIdentifier: 'slack-prod', + conversationId: 'conv-1', + isDM: false, + }); + + const firstPage = await adapter.fetchMessages(threadId, { limit: 2 }); + expect(firstPage.messages.map((message) => message.text)).toEqual(['msg-3', 'msg-4']); + expect(firstPage.nextCursor).toBe('3'); + + const secondPage = await adapter.fetchMessages(threadId, { + limit: 2, + cursor: firstPage.nextCursor, + }); + expect(secondPage.messages.map((message) => message.text)).toEqual(['msg-1', 'msg-2']); + expect(secondPage.nextCursor).toBe('1'); + }); +}); diff --git a/packages/chat-adapter/src/adapter.ts b/packages/chat-adapter/src/adapter.ts new file mode 100644 index 00000000000..a7f96ea7413 --- /dev/null +++ b/packages/chat-adapter/src/adapter.ts @@ -0,0 +1,552 @@ +import type { + AdapterPostableMessage, + ChatInstance, + Message as ChatMessage, + EmojiValue, + FetchOptions, + FetchResult, + FormattedContent, + RawMessage, + StateAdapter, + ThreadInfo, + UserInfo, + WebhookOptions, +} from 'chat'; +import { type ChatModuleParts, MessageMapper } from './message-mapper.js'; +import { ReplyClient } from './reply-client.js'; +import { patchSnapshotFromSignals, patchSnapshotResolved } from './snapshot-store.js'; +import { channelIdFromThreadId, decodeThreadId, encodeThreadId, isDMThreadId } from './thread-id.js'; +import { + type AgentBridgeRequest, + AgentEvent, + type AgentMessageAuthor, + type AgentReplyPayload, + type AgentSubscriber, + type NovuAdapterConfig, + type NovuRawMessage, + type NovuThreadId, + type NovuTypedAdapter, + type Signal, + type ThreadSnapshot, +} from './types.js'; +import { WebhookHandler } from './webhook-handler.js'; + +const SNAPSHOT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days +const DEDUPE_TTL_MS = 60 * 60 * 1000; // 1 hour + +class NotImplementedError extends Error { + constructor(method: string) { + super(`${method} is not supported by the Novu adapter`); + this.name = 'NotImplementedError'; + } +} + +const deliveryKey = (deliveryId: string): string => `novu:delivery:${deliveryId}`; +const snapshotKey = (threadId: string): string => `novu:snapshot:${threadId}`; +const subscriberKey = (subscriberId: string): string => `novu:subscriber:${subscriberId}`; +const DEFAULT_FETCH_LIMIT = 50; + +function paginateHistoryMessages(allMessages: ChatMessage[], options: FetchOptions): FetchResult { + const total = allMessages.length; + if (total === 0) { + return { messages: [] }; + } + + const direction = options.direction ?? 'backward'; + const limit = options.limit ?? DEFAULT_FETCH_LIMIT; + const cursor = options.cursor; + + if (direction === 'backward') { + const endExclusive = cursor === undefined ? total : Number(cursor); + if (cursor !== undefined && (Number.isNaN(endExclusive) || endExclusive <= 0 || endExclusive > total)) { + return { messages: [] }; + } + + const start = Math.max(0, endExclusive - limit); + const messages = allMessages.slice(start, endExclusive); + const nextCursor = start > 0 ? String(start) : undefined; + + return { messages, nextCursor }; + } + + const start = cursor === undefined ? 0 : Number(cursor); + if (cursor !== undefined && (Number.isNaN(start) || start < 0 || start >= total)) { + return { messages: [] }; + } + + const endExclusive = Math.min(total, start + limit); + const messages = allMessages.slice(start, endExclusive); + const nextCursor = endExclusive < total ? String(endExclusive) : undefined; + + return { messages, nextCursor }; +} + +export class NovuAdapterImpl implements NovuTypedAdapter { + readonly name = 'novu'; + readonly userName: string; + readonly persistMessageHistory = false; + + private readonly config: NovuAdapterConfig; + private readonly mapper = new MessageMapper(); + private readonly webhookHandler: WebhookHandler; + private readonly replyClient: ReplyClient; + private chat: ChatInstance | null = null; + private stringifyMarkdown!: (ast: FormattedContent) => string; + private getEmojiFn!: (name: string) => EmojiValue; + + constructor(config: NovuAdapterConfig) { + if (!config.apiKey) throw new Error('createNovuAdapter: `apiKey` is required'); + if (!config.agentIdentifier) throw new Error('createNovuAdapter: `agentIdentifier` is required'); + if (!config.bridgeSecret) throw new Error('createNovuAdapter: `bridgeSecret` is required'); + + this.config = config; + this.userName = `novu-agent-${config.agentIdentifier}`; + this.webhookHandler = new WebhookHandler(config.bridgeSecret, config.maxSignatureAgeMs); + this.replyClient = new ReplyClient(config); + } + + async initialize(chat: ChatInstance): Promise { + this.chat = chat; + + const chatModule = await import('chat'); + this.stringifyMarkdown = chatModule.stringifyMarkdown; + this.getEmojiFn = chatModule.getEmoji; + this.mapper.setChatModule({ + Message: chatModule.Message as unknown as ChatModuleParts['Message'], + parseMarkdown: chatModule.parseMarkdown, + stringifyMarkdown: chatModule.stringifyMarkdown, + toCardElement: chatModule.toCardElement as unknown as ChatModuleParts['toCardElement'], + isCardElement: chatModule.isCardElement, + }); + + if (this.config.bridgeUrl) { + // Boot-time bridge registration is best-effort — a failure here must not + // prevent the bridge from serving inbound requests. + try { + await this.replyClient.registerBridge(this.config.bridgeUrl); + } catch (err) { + this.chat?.getLogger('novu-adapter').warn('Failed to register bridge URL with Novu', { err }); + } + } + } + + private state(): StateAdapter { + if (!this.chat) { + throw new Error('Adapter not initialized. Call initialize() first.'); + } + + return this.chat.getState(); + } + + // -- Thread id -- + + encodeThreadId(data: NovuThreadId): string { + return encodeThreadId(data); + } + + decodeThreadId(threadId: string): NovuThreadId { + return decodeThreadId(threadId); + } + + channelIdFromThreadId(threadId: string): string { + return channelIdFromThreadId(threadId); + } + + isDM(threadId: string): boolean { + return isDMThreadId(threadId); + } + + // -- Inbound -- + + async handleWebhook(request: Request, options?: WebhookOptions): Promise { + if (!this.chat) { + throw new Error('Adapter not initialized. Call initialize() first.'); + } + + const { request: bridge, status } = await this.webhookHandler.parseAndVerify(request); + if (!bridge) { + return new Response(null, { status }); + } + + const state = this.state(); + const dedupeKey = deliveryKey(bridge.deliveryId); + + // Dedupe replayed deliveries (platform retries, at-least-once bridge delivery). + if (await state.get(dedupeKey)) { + return new Response(null, { status: 200 }); + } + + const threadId = this.threadIdFor(bridge); + + await this.cacheSnapshot(threadId, bridge); + + // Pre-seed subscription from server truth so an ongoing conversation routes to + // `onSubscribedMessage`. Novu persists inbound before building the bridge, so + // `history` already includes the current message on the first turn — use + // `messageCount` only. A brand-new conversation (messageCount === 1) stays + // unsubscribed and routes to `onNewMention` (use `thread.isDM` for first DM vs + // channel — do not register `onDirectMessage` if you want ongoing DMs on + // `onSubscribedMessage`). + if (bridge.conversation.messageCount > 1) { + await state.subscribe(threadId); + } + + switch (bridge.event) { + case AgentEvent.ON_MESSAGE: + await this.dispatchMessage(threadId, bridge, options); + break; + case AgentEvent.ON_ACTION: + await this.dispatchAction(threadId, bridge, options); + break; + case AgentEvent.ON_REACTION: + await this.dispatchReaction(threadId, bridge, options); + break; + case AgentEvent.ON_RESOLVE: + // ACK only in v1. + break; + default: + this.chat.getLogger('novu-adapter').warn('Unknown bridge event', { event: bridge.event }); + } + + await state.set(dedupeKey, '1', DEDUPE_TTL_MS); + + return new Response(null, { status: 200 }); + } + + private threadIdFor(bridge: AgentBridgeRequest): string { + return encodeThreadId({ + platform: bridge.platform, + integrationIdentifier: bridge.integrationIdentifier, + conversationId: bridge.conversationId, + isDM: bridge.platformContext?.isDM ?? false, + }); + } + + private async cacheSnapshot(threadId: string, bridge: AgentBridgeRequest): Promise { + const snapshot: ThreadSnapshot = { + history: bridge.history, + conversation: bridge.conversation, + subscriber: bridge.subscriber, + platform: bridge.platform, + platformContext: bridge.platformContext, + }; + const state = this.state(); + const writes: Promise[] = [this.saveSnapshot(threadId, snapshot)]; + + // Also index the subscriber by id so the SDK-native `getUser(userId)` can + // resolve it (the inbound author's `userId` is the `subscriberId`). + if (bridge.subscriber) { + writes.push(state.set(subscriberKey(bridge.subscriber.subscriberId), bridge.subscriber, SNAPSHOT_TTL_MS)); + } + + await Promise.all(writes); + } + + private async dispatchMessage(threadId: string, bridge: AgentBridgeRequest, options?: WebhookOptions): Promise { + if (!bridge.message || !this.chat) return; + + const raw = this.mapper.toRawMessage(bridge.message, { + conversationId: bridge.conversationId, + integrationIdentifier: bridge.integrationIdentifier, + platform: bridge.platform, + }); + const message = this.mapper.buildMessage(raw, threadId, this.humanAuthor(bridge)); + await this.chat.processMessage(this, threadId, message, options); + } + + private async dispatchAction(threadId: string, bridge: AgentBridgeRequest, options?: WebhookOptions): Promise { + if (!bridge.action || !this.chat) return; + + await this.chat.processAction( + { + actionId: bridge.action.id, + messageId: bridge.action.sourceMessageId ?? '', + value: bridge.action.value, + raw: bridge, + threadId, + user: this.mapper.toAuthor(this.humanAuthor(bridge)), + adapter: this, + }, + options + ); + } + + private async dispatchReaction( + threadId: string, + bridge: AgentBridgeRequest, + options?: WebhookOptions + ): Promise { + if (!bridge.reaction || !this.chat) return; + + const reactedMessage = bridge.reaction.message + ? this.mapper.buildMessage( + this.mapper.toRawMessage(bridge.reaction.message, { + conversationId: bridge.conversationId, + integrationIdentifier: bridge.integrationIdentifier, + platform: bridge.platform, + }), + threadId + ) + : undefined; + + await this.chat.processReaction( + { + added: bridge.reaction.added, + emoji: this.getEmojiFn(bridge.reaction.emoji.name), + message: reactedMessage, + messageId: bridge.reaction.messageId, + rawEmoji: bridge.reaction.emoji.name, + raw: bridge, + threadId, + user: this.mapper.toAuthor(this.humanAuthor(bridge)), + adapter: this, + }, + options + ); + } + + /** + * Canonical human-actor identity for inbound messages, actions, and reactions. + * The Novu subscriber is the source of truth, so `userId` is the + * `subscriberId` — this keeps `message.author`, `getParticipants()`, and + * `adapter.getUser(userId)` consistent. The platform-native `userName` / + * `fullName` are preserved (the raw platform author stays on + * `message.raw.author`). Falls back to the platform author when no subscriber + * is present. + */ + private humanAuthor(bridge: AgentBridgeRequest): AgentMessageAuthor { + const platformAuthor = bridge.message?.author; + const sub = bridge.subscriber; + + if (!sub) { + return ( + platformAuthor ?? { + userId: 'novu-subscriber', + userName: 'novu-subscriber', + fullName: 'Subscriber', + isBot: false, + } + ); + } + + const fullName = [sub.firstName, sub.lastName].filter(Boolean).join(' '); + + return { + userId: sub.subscriberId, + userName: platformAuthor?.userName ?? sub.subscriberId, + fullName: fullName || platformAuthor?.fullName || sub.subscriberId, + isBot: false, + }; + } + + parseMessage(raw: NovuRawMessage): ChatMessage { + const threadId = encodeThreadId({ + platform: raw.platform, + integrationIdentifier: raw.integrationIdentifier, + conversationId: raw.conversationId, + isDM: false, + }); + + return this.mapper.buildMessage(raw, threadId); + } + + // -- Outbound -- + + async postMessage(threadId: string, message: AdapterPostableMessage): Promise> { + const decoded = decodeThreadId(threadId); + const info = await this.replyClient.send( + this.replyPayload(decoded, { + reply: await this.mapper.toReplyContent(message), + }) + ); + const messageId = info?.messageId ?? `novu-reply:${decoded.conversationId}`; + + return { + id: messageId, + raw: this.outboundRaw(decoded, messageId), + threadId, + }; + } + + async editMessage( + threadId: string, + messageId: string, + message: AdapterPostableMessage + ): Promise> { + const decoded = decodeThreadId(threadId); + const info = await this.replyClient.send( + this.replyPayload(decoded, { + edit: { messageId, content: await this.mapper.toReplyContent(message) }, + }) + ); + const resolvedId = info?.messageId ?? messageId; + + return { + id: resolvedId, + raw: this.outboundRaw(decoded, resolvedId), + threadId, + }; + } + + async addReaction(threadId: string, messageId: string, emoji: EmojiValue | string): Promise { + const decoded = decodeThreadId(threadId); + await this.replyClient.send( + this.replyPayload(decoded, { + addReactions: [{ messageId, emojiName: this.emojiName(emoji) }], + }) + ); + } + + /** Emit raw signals (used by `getNovuContext().trigger` / `.setMetadata`). */ + async emitSignals(threadId: string, signals: Signal[]): Promise { + const decoded = decodeThreadId(threadId); + await this.replyClient.send(this.replyPayload(decoded, { signals })); + await this.patchSnapshotAfterSignals(threadId, signals); + } + + /** Emit a resolve (used by `getNovuContext().resolve`). */ + async emitResolve(threadId: string, summary?: string): Promise { + const decoded = decodeThreadId(threadId); + await this.replyClient.send(this.replyPayload(decoded, { resolve: { summary } })); + + const snapshot = await this.getSnapshot(threadId); + if (snapshot) { + await this.saveSnapshot(threadId, patchSnapshotResolved(snapshot)); + } + } + + renderFormatted(content: FormattedContent): string { + return this.stringifyMarkdown(content); + } + + // -- Thread metadata -- + + async fetchThread(threadId: string): Promise { + const snapshot = await this.getSnapshot(threadId); + const decoded = decodeThreadId(threadId); + + return { + id: threadId, + channelId: channelIdFromThreadId(threadId), + isDM: decoded.isDM, + metadata: snapshot + ? { + conversationId: snapshot.conversation.identifier, + status: snapshot.conversation.status, + platform: snapshot.platform, + ...snapshot.conversation.metadata, + } + : { + conversationId: decoded.conversationId, + platform: decoded.platform, + }, + }; + } + + async fetchMessages(threadId: string, options: FetchOptions = {}): Promise> { + const snapshot = await this.getSnapshot(threadId); + if (!snapshot) { + return { messages: [] }; + } + const decoded = decodeThreadId(threadId); + const allMessages = snapshot.history.map((entry, index) => + this.mapper.buildHistoryMessage(entry, index, threadId, decoded.integrationIdentifier, decoded.platform) + ); + + return paginateHistoryMessages(allMessages, options); + } + + /** Cached bridge snapshot for a thread (single source for NovuContext reads). */ + async getSnapshot(threadId: string): Promise { + return this.state().get(snapshotKey(threadId)); + } + + /** + * SDK-native user lookup. Resolves the Novu subscriber indexed by id (the + * inbound author's `userId` is the `subscriberId`) and maps it to the + * portable `UserInfo` shape. Returns `null` for unknown ids. Novu-specific + * fields (`phone`, `locale`, `data`) are not part of `UserInfo` — use + * `getNovuContext(thread).getSubscriber()` for those. + */ + async getUser(userId: string): Promise { + const subscriber = await this.state().get(subscriberKey(userId)); + if (!subscriber) { + return null; + } + const fullName = [subscriber.firstName, subscriber.lastName].filter(Boolean).join(' '); + + return { + userId: subscriber.subscriberId, + userName: subscriber.subscriberId, + fullName: fullName || subscriber.subscriberId, + email: subscriber.email, + avatarUrl: subscriber.avatar, + isBot: false, + }; + } + + // -- Unsupported / no-op operations -- + + async startTyping(): Promise { + // Novu routes the reply when it is ready; there is no typing channel. + } + + async removeReaction(): Promise { + // Novu's reply API only supports adding reactions. + } + + async deleteMessage(): Promise { + throw new NotImplementedError('deleteMessage'); + } + + // -- helpers -- + + private async saveSnapshot(threadId: string, snapshot: ThreadSnapshot): Promise { + await this.state().set(snapshotKey(threadId), snapshot, SNAPSHOT_TTL_MS); + } + + private async patchSnapshotAfterSignals(threadId: string, signals: Signal[]): Promise { + const snapshot = await this.getSnapshot(threadId); + if (!snapshot) { + return; + } + + const patched = patchSnapshotFromSignals(snapshot, signals); + if (patched) { + await this.saveSnapshot(threadId, patched); + } + } + + private replyPayload(decoded: NovuThreadId, rest: Partial): AgentReplyPayload { + return { + conversationId: decoded.conversationId, + integrationIdentifier: decoded.integrationIdentifier, + ...rest, + }; + } + + private outboundRaw(decoded: NovuThreadId, messageId: string): NovuRawMessage { + return { + id: messageId, + text: '', + author: { + userId: this.userName, + userName: this.userName, + fullName: this.userName, + isBot: true, + }, + timestamp: new Date().toISOString(), + conversationId: decoded.conversationId, + integrationIdentifier: decoded.integrationIdentifier, + platform: decoded.platform, + }; + } + + private emojiName(emoji: EmojiValue | string): string { + if (typeof emoji === 'string') { + return emoji; + } + + return emoji?.name ?? String(emoji); + } +} diff --git a/packages/chat-adapter/src/index.ts b/packages/chat-adapter/src/index.ts new file mode 100644 index 00000000000..941aec5cbf5 --- /dev/null +++ b/packages/chat-adapter/src/index.ts @@ -0,0 +1,98 @@ +import { NovuAdapterImpl } from "./adapter.js"; +import type { NovuAdapter, NovuAdapterConfig } from "./types.js"; + +export { getNovuContext } from "./novu-context.js"; +export { verifyNovuSignature } from "./signature.js"; + +export type { + AddReactionPayload, + AgentAction, + AgentAttachment, + AgentBridgeRequest, + AgentConversation, + AgentEmailContext, + AgentEmailDomainContext, + AgentEmailRouteContext, + AgentHistoryEntry, + AgentMessage, + AgentMessageAuthor, + AgentReaction, + AgentReplyPayload, + AgentSubscriber, + NovuAdapter, + NovuAdapterConfig, + NovuContext, + NovuHistoryFields, + NovuRawMessage, + NovuThreadId, + NovuTypedAdapter, + ReplyContent, + ReplyFileRef, + Signal, + TriggerRecipientsPayload, +} from "./types.js"; +export { AgentEvent } from "./types.js"; + +/** + * Create a Chat SDK adapter that exposes Novu's normalized chat channels + * (Slack, WhatsApp, Teams, Telegram, Email) as one platform. The developer's + * Chat SDK app becomes the bridge: one handler set serves all channels. + * + * Credentials fall back to environment variables when omitted: + * `NOVU_SECRET_KEY` (`apiKey` + `bridgeSecret`), `NOVU_AGENT_IDENTIFIER` + * (`agentIdentifier`), `NOVU_API_BASE_URL` (`apiBaseUrl`), and + * `NOVU_BRIDGE_URL` (`bridgeUrl`). Explicit config always takes precedence. + * + * @example + * import { Chat } from 'chat'; + * import { createNovuAdapter } from '@novu/chat-sdk-adapter'; + * import { createMemoryState } from '@chat-adapter/state-memory'; + * + * // Reads NOVU_SECRET_KEY + NOVU_AGENT_IDENTIFIER from the environment: + * const novu = createNovuAdapter(); + * + * const chat = new Chat({ userName: 'support', adapters: { novu }, state: createMemoryState() }); + * + * chat.onNewMention(async (thread, message) => { + * if (thread.isDM) await thread.post(`Hi (DM)! You said: ${message.text}`); + * else await thread.post(`Hi! You said: ${message.text}`); + * }); + * chat.onSubscribedMessage(async (thread, message) => { + * await thread.post(`echo: ${message.text}`); + * }); + */ +export function createNovuAdapter( + config: Partial = {}, +): NovuAdapter { + const env = typeof process !== "undefined" ? process.env : undefined; + const apiKey = config.apiKey ?? env?.NOVU_SECRET_KEY; + const bridgeSecret = config.bridgeSecret ?? env?.NOVU_SECRET_KEY; + const agentIdentifier = config.agentIdentifier ?? env?.NOVU_AGENT_IDENTIFIER; + const apiBaseUrl = config.apiBaseUrl ?? env?.NOVU_API_BASE_URL; + const bridgeUrl = config.bridgeUrl ?? env?.NOVU_BRIDGE_URL; + + if (!apiKey) { + throw new Error( + "createNovuAdapter: `apiKey` is required (pass it or set NOVU_SECRET_KEY).", + ); + } + if (!agentIdentifier) { + throw new Error( + "createNovuAdapter: `agentIdentifier` is required (pass it or set NOVU_AGENT_IDENTIFIER).", + ); + } + if (!bridgeSecret) { + throw new Error( + "createNovuAdapter: `bridgeSecret` is required (pass it or set NOVU_SECRET_KEY).", + ); + } + + return new NovuAdapterImpl({ + ...config, + apiKey, + agentIdentifier, + bridgeSecret, + apiBaseUrl, + bridgeUrl, + }) as NovuAdapter; +} diff --git a/packages/chat-adapter/src/message-mapper.ts b/packages/chat-adapter/src/message-mapper.ts new file mode 100644 index 00000000000..ec539e29148 --- /dev/null +++ b/packages/chat-adapter/src/message-mapper.ts @@ -0,0 +1,247 @@ +import type { + AdapterPostableMessage, + Attachment, + Author, + CardElement, + Message as ChatMessage, + MessageData, + Root, +} from 'chat'; +import { mapReplyFiles } from './reply-files.js'; +import type { + AgentAttachment, + AgentHistoryEntry, + AgentMessage, + AgentMessageAuthor, + NovuRawMessage, + ReplyContent, +} from './types.js'; + +const ATTACHMENT_TYPES = new Set(['image', 'file', 'video', 'audio']); + +/** Chat-module functions the mapper needs, injected after the dynamic `import('chat')`. */ +export interface ChatModuleParts { + Message: new (data: MessageData) => ChatMessage; + parseMarkdown: (md: string) => Root; + stringifyMarkdown: (ast: Root) => string; + toCardElement: (element: unknown) => CardElement; + isCardElement: (value: unknown) => value is CardElement; +} + +export class MessageMapper { + private parts!: ChatModuleParts; + + setChatModule(parts: ChatModuleParts): void { + this.parts = parts; + } + + // -- inbound: bridge -> chat -- + + toRawMessage( + message: AgentMessage, + ctx: { + conversationId: string; + integrationIdentifier: string; + platform: string; + } + ): NovuRawMessage { + return { + id: message.platformMessageId, + text: message.text, + author: message.author, + timestamp: message.timestamp, + attachments: message.attachments, + conversationId: ctx.conversationId, + integrationIdentifier: ctx.integrationIdentifier, + platform: ctx.platform, + }; + } + + /** + * Build a chat `Message`. `isMention` is forced `true`: Novu only bridges + * messages already directed at the agent, so first-message routing must reach + * `onNewMention` (for channel messages) rather than being dropped. + * + * `authorOverride` lets the adapter present the Novu subscriber as the message + * author (so `author.userId === subscriberId` and `adapter.getUser(userId)` + * resolves). The platform-native author is preserved on `message.raw.author`. + */ + buildMessage( + raw: NovuRawMessage, + threadId: string, + authorOverride?: AgentMessageAuthor + ): ChatMessage { + const dateSent = parseDate(raw.timestamp); + + return new this.parts.Message({ + id: raw.id, + threadId, + text: raw.text, + formatted: this.parts.parseMarkdown(raw.text ?? ''), + raw, + author: this.toAuthor(authorOverride ?? raw.author), + metadata: { dateSent, edited: false }, + attachments: (raw.attachments ?? []).map(toChatAttachment), + isMention: true, + }); + } + + /** + * Build a chat `Message` from a Novu history entry (used by `fetchMessages`). + * Preserves `role`, `type`, `richContent`, and `signalData` on `message.raw.history` + * and maps signed attachments from `richContent.attachments` when present. + */ + buildHistoryMessage( + entry: AgentHistoryEntry, + index: number, + threadId: string, + integrationIdentifier: string, + platform: string + ): ChatMessage { + const isAssistant = entry.role === 'assistant' || entry.role === 'system'; + const historyAttachments = attachmentsFromRichContent(entry.richContent); + const raw: NovuRawMessage = { + id: `novu-history:${index}`, + text: entry.content, + author: { + userId: isAssistant ? 'novu-agent' : 'novu-subscriber', + fullName: entry.senderName ?? (isAssistant ? 'Agent' : 'User'), + userName: entry.senderName ?? entry.role, + isBot: isAssistant, + }, + timestamp: entry.createdAt, + attachments: historyAttachments, + conversationId: '', + integrationIdentifier, + platform, + history: { + role: entry.role, + type: entry.type, + richContent: entry.richContent, + signalData: entry.signalData, + }, + }; + + return new this.parts.Message({ + id: raw.id, + threadId, + text: entry.content, + formatted: this.parts.parseMarkdown(entry.content ?? ''), + raw, + author: this.toAuthor(raw.author, isAssistant), + metadata: { dateSent: parseDate(entry.createdAt), edited: false }, + attachments: historyAttachments.map(toChatAttachment), + }); + } + + toAuthor(author: AgentMessageAuthor, isMe = false): Author { + return { + userId: author.userId, + userName: author.userName, + fullName: author.fullName, + isBot: author.isBot, + isMe, + }; + } + + // -- outbound: AdapterPostableMessage -> ReplyContent -- + + async toReplyContent(message: AdapterPostableMessage): Promise { + if (typeof message === 'string') { + return { markdown: message }; + } + if (this.parts.isCardElement(message)) { + return { card: message }; + } + if (typeof message === 'object' && message !== null) { + const obj = message as unknown as Record; + const files = await mapReplyFiles(obj.files ?? obj.attachments); + + if (typeof obj.markdown === 'string') { + return files ? { markdown: obj.markdown, files } : { markdown: obj.markdown }; + } + if (typeof obj.raw === 'string') { + return files ? { markdown: obj.raw, files } : { markdown: obj.raw }; + } + if (obj.ast) { + const markdown = this.parts.stringifyMarkdown(obj.ast as Root); + + return files ? { markdown, files } : { markdown }; + } + if (obj.card !== undefined) { + const card = this.toCard(obj.card); + + return files ? { card, files } : { card }; + } + if (obj.type === 'card') { + const card = this.toCard(message); + + return files ? { card, files } : { card }; + } + } + + throw new Error('Unsupported message content passed to Novu adapter'); + } + + private toCard(value: unknown): CardElement { + return this.parts.isCardElement(value) ? value : this.parts.toCardElement(value); + } +} + +function parseDate(value: string | undefined): Date { + if (!value) { + return new Date(0); + } + const date = new Date(value); + + return Number.isNaN(date.getTime()) ? new Date(0) : date; +} + +function attachmentsFromRichContent(richContent?: Record): AgentAttachment[] { + const raw = richContent?.attachments; + if (!Array.isArray(raw)) { + return []; + } + + const attachments: AgentAttachment[] = []; + + for (const item of raw) { + if (!item || typeof item !== 'object') { + continue; + } + + const att = item as Record; + attachments.push({ + type: typeof att.type === 'string' ? att.type : 'file', + url: typeof att.url === 'string' ? att.url : undefined, + name: typeof att.name === 'string' ? att.name : undefined, + mimeType: typeof att.mimeType === 'string' ? att.mimeType : undefined, + size: typeof att.size === 'number' ? att.size : undefined, + }); + } + + return attachments; +} + +function toChatAttachment(att: AgentAttachment): Attachment { + const type = normalizeAttachmentType(att.type, att.mimeType); + + return { + type, + url: att.url, + name: att.name, + mimeType: att.mimeType, + size: att.size, + }; +} + +function normalizeAttachmentType(type: string | undefined, mimeType: string | undefined): Attachment['type'] { + if (type && ATTACHMENT_TYPES.has(type)) { + return type as Attachment['type']; + } + if (mimeType?.startsWith('image/')) return 'image'; + if (mimeType?.startsWith('video/')) return 'video'; + if (mimeType?.startsWith('audio/')) return 'audio'; + + return 'file'; +} diff --git a/packages/chat-adapter/src/novu-context.ts b/packages/chat-adapter/src/novu-context.ts new file mode 100644 index 00000000000..8d0cb54826a --- /dev/null +++ b/packages/chat-adapter/src/novu-context.ts @@ -0,0 +1,54 @@ +import type { NovuContext, NovuContextSource, Signal } from './types.js'; + +/** + * Opt-in Novu-only capabilities for a thread. Ported Chat SDK bots ignore this; + * Novu-aware handlers call it to trigger workflows, read conversation state, + * persist metadata, or resolve the conversation. Each mutating call emits its own + * reply POST. + * + * @example + * chat.onSubscribedMessage(async (thread, message) => { + * const novu = getNovuContext(thread); + * const history = await novu.getHistory(); + * const llmMessages = history.map((h) => ({ role: h.role, content: h.content })); + * if (novu.platform === 'whatsapp') { + * await novu.trigger('escalation', { payload: { text: message.text } }); + * } + * }); + */ +export function getNovuContext(thread: { id: string; adapter: unknown }): NovuContext { + const source = thread.adapter as unknown as NovuContextSource; + if ( + typeof source?.emitSignals !== 'function' || + typeof source?.decodeThreadId !== 'function' || + typeof source?.getSnapshot !== 'function' + ) { + throw new Error('getNovuContext() requires a thread owned by the Novu adapter'); + } + + const threadId = thread.id; + const { platform } = source.decodeThreadId(threadId); + + const emit = (signal: Signal) => source.emitSignals(threadId, [signal]); + const snapshot = () => source.getSnapshot(threadId); + + return { + platform, + getSubscriber: async () => (await snapshot())?.subscriber ?? null, + getConversation: async () => (await snapshot())?.conversation ?? null, + getHistory: async () => (await snapshot())?.history ?? [], + getEmailContext: async () => (await snapshot())?.platformContext?.email ?? null, + getMetadata: async (key) => (await snapshot())?.conversation.metadata?.[key], + trigger: (workflowId, opts) => + emit({ + type: 'trigger', + workflowId, + to: opts?.to, + payload: opts?.payload, + }), + setMetadata: (key, value) => emit({ type: 'metadata', action: 'set', key, value }), + deleteMetadata: (key) => emit({ type: 'metadata', action: 'delete', key }), + clearMetadata: () => emit({ type: 'metadata', action: 'clear' }), + resolve: (summary) => source.emitResolve(threadId, summary), + }; +} diff --git a/packages/chat-adapter/src/reply-client.spec.ts b/packages/chat-adapter/src/reply-client.spec.ts new file mode 100644 index 00000000000..a3046fc4914 --- /dev/null +++ b/packages/chat-adapter/src/reply-client.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ReplyClient } from './reply-client.js'; +import type { NovuAdapterConfig } from './types.js'; + +function makeClient(overrides: Partial = {}) { + const fetchMock = vi.fn( + async () => new Response(JSON.stringify({ messageId: 'm-1', platformThreadId: 't-1' }), { status: 200 }) + ); + const config: NovuAdapterConfig = { + apiKey: 'secret-123', + agentIdentifier: 'support-agent', + bridgeSecret: 'bridge-secret', + fetch: fetchMock as unknown as typeof fetch, + ...overrides, + }; + + return { client: new ReplyClient(config), fetchMock }; +} + +describe('ReplyClient', () => { + it('derives the reply URL from apiBaseUrl + agentIdentifier (default cloud)', () => { + const { client } = makeClient(); + expect(client.getReplyUrl()).toBe('https://api.novu.co/v1/agents/support-agent/reply'); + }); + + it('honors a custom apiBaseUrl and strips a trailing slash', () => { + const { client } = makeClient({ apiBaseUrl: 'https://eu.api.novu.co/' }); + expect(client.getReplyUrl()).toBe('https://eu.api.novu.co/v1/agents/support-agent/reply'); + }); + + it('posts with ApiKey auth and JSON body, returning SentMessageInfo', async () => { + const { client, fetchMock } = makeClient(); + const info = await client.send({ + conversationId: 'c1', + integrationIdentifier: 'slack-prod', + reply: { markdown: 'hi' }, + }); + + expect(info).toEqual({ messageId: 'm-1', platformThreadId: 't-1' }); + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe('https://api.novu.co/v1/agents/support-agent/reply'); + expect(init.method).toBe('POST'); + expect((init.headers as Record).authorization).toBe('ApiKey secret-123'); + expect(JSON.parse(init.body as string)).toEqual({ + conversationId: 'c1', + integrationIdentifier: 'slack-prod', + reply: { markdown: 'hi' }, + }); + }); + + it('throws on non-2xx responses', async () => { + const fetchMock = vi.fn(async () => new Response('nope', { status: 403, statusText: 'Forbidden' })); + const client = new ReplyClient({ + apiKey: 'k', + agentIdentifier: 'a', + bridgeSecret: 'b', + fetch: fetchMock as unknown as typeof fetch, + }); + + await expect(client.send({ conversationId: 'c', integrationIdentifier: 'i' })).rejects.toThrow(/403/); + }); +}); diff --git a/packages/chat-adapter/src/reply-client.ts b/packages/chat-adapter/src/reply-client.ts new file mode 100644 index 00000000000..18fe99564b8 --- /dev/null +++ b/packages/chat-adapter/src/reply-client.ts @@ -0,0 +1,74 @@ +import type { AgentReplyPayload, NovuAdapterConfig, SentMessageInfo } from './types.js'; + +const DEFAULT_API_BASE_URL = 'https://api.novu.co'; + +/** + * Posts `AgentReplyPayload`s to Novu's reply endpoint. + * + * The reply URL is derived solely from `apiBaseUrl` + `agentIdentifier`; the + * inbound bridge request's `replyUrl` is never used, so a forged request can + * never redirect the `apiKey` to an attacker-controlled host. + */ +export class ReplyClient { + private readonly replyUrl: string; + private readonly bridgeUrl: string; + private readonly apiKey: string; + private readonly fetchImpl: typeof fetch; + + constructor(config: NovuAdapterConfig) { + const base = (config.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, ''); + const id = encodeURIComponent(config.agentIdentifier); + this.replyUrl = `${base}/v1/agents/${id}/reply`; + this.bridgeUrl = `${base}/v1/agents/${id}/bridge`; + this.apiKey = config.apiKey; + this.fetchImpl = config.fetch ?? globalThis.fetch; + } + + /** The derived reply URL (exposed for assertions/tests). */ + getReplyUrl(): string { + return this.replyUrl; + } + + async send(payload: AgentReplyPayload): Promise { + const response = await this.fetchImpl(this.replyUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `ApiKey ${this.apiKey}`, + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const detail = await response.text().catch(() => ''); + throw new Error(`Novu reply failed (${response.status} ${response.statusText}): ${detail}`); + } + + const text = await response.text().catch(() => ''); + if (!text) { + return null; + } + try { + return JSON.parse(text) as SentMessageInfo; + } catch { + return null; + } + } + + /** Register the bridge endpoint for an agent (boot-time, optional). */ + async registerBridge(bridgeUrl: string): Promise { + const response = await this.fetchImpl(this.bridgeUrl, { + method: 'PUT', + headers: { + 'content-type': 'application/json', + authorization: `ApiKey ${this.apiKey}`, + }, + body: JSON.stringify({ bridgeUrl }), + }); + + if (!response.ok) { + const detail = await response.text().catch(() => ''); + throw new Error(`Bridge registration failed (${response.status} ${response.statusText}): ${detail}`); + } + } +} diff --git a/packages/chat-adapter/src/reply-files.ts b/packages/chat-adapter/src/reply-files.ts new file mode 100644 index 00000000000..9fe84a9305e --- /dev/null +++ b/packages/chat-adapter/src/reply-files.ts @@ -0,0 +1,180 @@ +import type { Attachment } from 'chat'; +import type { ReplyFileRef } from './types.js'; + +const MAX_INLINE_FILE_BYTES = 5 * 1024 * 1024; +const CHUNK_SIZE = 0x8000; +const BASE64_REGEX = /^[A-Za-z0-9+/]*={0,2}$/; + +type FileUploadLike = { + data: Buffer | Blob | ArrayBuffer | Uint8Array; + filename: string; + mimeType?: string; +}; + +function getGlobalBuffer(): + | { + isBuffer?: (value: unknown) => boolean; + from: (value: ArrayBuffer | Uint8Array) => { toString: (encoding: 'base64') => string }; + } + | undefined { + return ( + globalThis as typeof globalThis & { + Buffer?: { + isBuffer?: (value: unknown) => boolean; + from: (value: ArrayBuffer | Uint8Array) => { toString: (encoding: 'base64') => string }; + }; + } + ).Buffer; +} + +function isBuffer(value: unknown): value is Buffer { + return getGlobalBuffer()?.isBuffer?.(value) ?? false; +} + +function bytesToBase64(bytes: Uint8Array): string { + const globalBuffer = getGlobalBuffer(); + if (globalBuffer) { + return globalBuffer.from(bytes).toString('base64'); + } + + if (typeof btoa !== 'function') { + throw new Error('Unable to encode file data: base64 encoding is not available in this runtime.'); + } + + let binary = ''; + for (let offset = 0; offset < bytes.length; offset += CHUNK_SIZE) { + const chunk = bytes.subarray(offset, offset + CHUNK_SIZE); + binary += String.fromCharCode(...chunk); + } + + return btoa(binary); +} + +function decodedBase64Length(value: string): number | null { + const normalized = value.replace(/\s/g, ''); + const remainder = normalized.length % 4; + + if (!normalized || remainder === 1 || !BASE64_REGEX.test(normalized)) { + return null; + } + + const padding = normalized.endsWith('==') ? 2 : normalized.endsWith('=') ? 1 : 0; + + return Math.floor((normalized.length * 3) / 4) - padding; +} + +function assertInlineFileSize(size: number, filename: string): void { + if (size > MAX_INLINE_FILE_BYTES) { + throw new Error( + `Invalid file "${filename}": inline data must be 5 MB or smaller. Use a publicly-accessible URL for larger files.` + ); + } +} + +async function encodeFileData(data: FileUploadLike['data'], filename: string): Promise { + if (typeof data === 'string') { + const decodedLength = decodedBase64Length(data); + if (decodedLength === null) { + throw new Error(`Invalid file "${filename}": data must be a base64-encoded string.`); + } + + assertInlineFileSize(decodedLength, filename); + + return data; + } + + if (isBuffer(data)) { + assertInlineFileSize(data.byteLength, filename); + + return data.toString('base64'); + } + + if (data instanceof Uint8Array) { + assertInlineFileSize(data.byteLength, filename); + + return bytesToBase64(data); + } + + if (data instanceof ArrayBuffer) { + assertInlineFileSize(data.byteLength, filename); + + return bytesToBase64(new Uint8Array(data)); + } + + if (typeof Blob !== 'undefined' && data instanceof Blob) { + assertInlineFileSize(data.size, filename); + + return bytesToBase64(new Uint8Array(await data.arrayBuffer())); + } + + throw new Error( + `Invalid file "${filename}": data must be a base64 string, Buffer, Uint8Array, ArrayBuffer, or Blob.` + ); +} + +function isFileUploadLike(value: unknown): value is FileUploadLike { + if (typeof value !== 'object' || value === null) { + return false; + } + + const obj = value as Record; + + return typeof obj.filename === 'string' && obj.data !== undefined && obj.data !== null; +} + +async function attachmentToFileRef(att: Attachment): Promise { + const filename = att.name ?? 'attachment'; + + if (att.url) { + return { + filename, + mimeType: att.mimeType, + url: att.url, + }; + } + + if (att.data) { + return { + filename, + mimeType: att.mimeType, + data: await encodeFileData(att.data as FileUploadLike['data'], filename), + }; + } + + return null; +} + +async function fileUploadToRef(upload: FileUploadLike): Promise { + return { + filename: upload.filename, + mimeType: upload.mimeType, + data: await encodeFileData(upload.data, upload.filename), + }; +} + +/** Map chat-sdk postable `files` / `attachments` to Novu `ReplyFileRef`s. */ +export async function mapReplyFiles(files: unknown): Promise { + if (!Array.isArray(files) || files.length === 0) { + return undefined; + } + + const refs: ReplyFileRef[] = []; + + for (const item of files) { + if (isFileUploadLike(item)) { + refs.push(await fileUploadToRef(item)); + continue; + } + + const attRef = await attachmentToFileRef(item as Attachment); + if (attRef) { + refs.push(attRef); + } + } + + if (refs.length === 0) { + return undefined; + } + + return refs; +} diff --git a/packages/chat-adapter/src/signature.spec.ts b/packages/chat-adapter/src/signature.spec.ts new file mode 100644 index 00000000000..29d96453d19 --- /dev/null +++ b/packages/chat-adapter/src/signature.spec.ts @@ -0,0 +1,57 @@ +import { createHmac } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { verifyNovuSignature } from './signature.js'; + +const SECRET = 'super-secret-key'; + +function sign(body: string, timestamp: number, secret = SECRET): string { + const hmac = createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex'); + + return `t=${timestamp},v1=${hmac}`; +} + +describe('verifyNovuSignature', () => { + const now = 1_700_000_000_000; + const body = JSON.stringify({ conversationId: 'c1', event: 'onMessage' }); + + it('accepts a valid, fresh signature', () => { + const header = sign(body, now); + expect(verifyNovuSignature(header, body, SECRET, { now: () => now })).toBe(true); + }); + + it('rejects a missing header', () => { + expect(verifyNovuSignature(null, body, SECRET, { now: () => now })).toBe(false); + }); + + it('rejects a tampered body', () => { + const header = sign(body, now); + expect(verifyNovuSignature(header, `${body} `, SECRET, { now: () => now })).toBe(false); + }); + + it('rejects a wrong secret', () => { + const header = sign(body, now, 'other-secret'); + expect(verifyNovuSignature(header, body, SECRET, { now: () => now })).toBe(false); + }); + + it('rejects a stale timestamp beyond max age', () => { + const header = sign(body, now - 10 * 60 * 1000); + expect(verifyNovuSignature(header, body, SECRET, { now: () => now })).toBe(false); + }); + + it('rejects a timestamp too far in the future', () => { + const header = sign(body, now + 60 * 1000); + expect(verifyNovuSignature(header, body, SECRET, { now: () => now })).toBe(false); + }); + + it('rejects malformed headers', () => { + for (const header of ['', 'garbage', 't=123', 'v1=abc', `t=${now}`]) { + expect(verifyNovuSignature(header, body, SECRET, { now: () => now })).toBe(false); + } + }); + + it('rejects invalid hex HMAC without throwing', () => { + const header = `t=${now},v1=${'g'.repeat(64)}`; + + expect(verifyNovuSignature(header, body, SECRET, { now: () => now })).toBe(false); + }); +}); diff --git a/packages/chat-adapter/src/signature.ts b/packages/chat-adapter/src/signature.ts new file mode 100644 index 00000000000..d5f08d7e25b --- /dev/null +++ b/packages/chat-adapter/src/signature.ts @@ -0,0 +1,69 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +const SIGNATURE_HEADER = 'novu-signature'; +const DEFAULT_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes +const MAX_FUTURE_SKEW_MS = 30 * 1000; // 30s tolerance for clock drift + +export interface VerifyOptions { + maxAgeMs?: number; + /** Injectable clock for deterministic tests. */ + now?: () => number; +} + +/** + * Verify the `novu-signature` HMAC produced by Novu's `buildNovuSignatureHeader` + * (libs/application-generic/src/utils/hmac.ts): + * + * header = `t=,v1=` + * message = `.` + * hmac = HMAC-SHA256(secret, message) as lowercase hex + * + * The timestamp is milliseconds since epoch. `rawBody` MUST be the exact bytes of + * the request body — Novu signs `JSON.stringify(payload)` and sends those same + * bytes (`safeOutboundJsonRequest` ends with `JSON.stringify(body)`), so verifying + * against `request.text()` is byte-identical. + */ +export function verifyNovuSignature( + signatureHeader: string | null, + rawBody: string, + secret: string, + options: VerifyOptions = {} +): boolean { + if (!signatureHeader) { + return false; + } + + const parts = signatureHeader.split(','); + const timestampPart = parts.find((p) => p.startsWith('t=')); + const hmacPart = parts.find((p) => p.startsWith('v1=')); + if (!timestampPart || !hmacPart) { + return false; + } + + const timestamp = timestampPart.slice(2); + const receivedHmac = hmacPart.slice(3); + + const now = options.now?.() ?? Date.now(); + const maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS; + const age = now - Number(timestamp); + if (Number.isNaN(age) || age > maxAgeMs || age < -MAX_FUTURE_SKEW_MS) { + return false; + } + + const expectedHmac = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex'); + if (receivedHmac.length !== expectedHmac.length) { + return false; + } + + const received = Buffer.from(receivedHmac, 'hex'); + const expected = Buffer.from(expectedHmac, 'hex'); + if (received.length !== expected.length) { + return false; + } + + return timingSafeEqual(received, expected); +} + +export function getSignatureHeader(request: Request): string | null { + return request.headers.get(SIGNATURE_HEADER); +} diff --git a/packages/chat-adapter/src/snapshot-store.spec.ts b/packages/chat-adapter/src/snapshot-store.spec.ts new file mode 100644 index 00000000000..9ddb38da6aa --- /dev/null +++ b/packages/chat-adapter/src/snapshot-store.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { applyMetadataSignals, patchSnapshotFromSignals, patchSnapshotResolved } from './snapshot-store.js'; +import type { ThreadSnapshot } from './types.js'; + +const baseSnapshot = (): ThreadSnapshot => ({ + history: [], + conversation: { + identifier: 'conv-1', + status: 'open', + metadata: { ticketId: 'T-42' }, + messageCount: 2, + createdAt: '2026-01-01T00:00:00.000Z', + lastActivityAt: '2026-01-02T00:00:00.000Z', + }, + subscriber: null, + platform: 'slack', + platformContext: { threadId: 't', channelId: 'c', isDM: false }, +}); + +describe('snapshot-store', () => { + it('applies set, delete, and clear metadata signals', () => { + let metadata = { ticketId: 'T-42', keep: 'yes' }; + + ({ metadata } = applyMetadataSignals(metadata, [ + { type: 'metadata', action: 'set', key: 'ticketId', value: 'T-99' }, + ])); + expect(metadata).toEqual({ ticketId: 'T-99', keep: 'yes' }); + + ({ metadata } = applyMetadataSignals(metadata, [{ type: 'metadata', action: 'delete', key: 'keep' }])); + expect(metadata).toEqual({ ticketId: 'T-99' }); + + ({ metadata } = applyMetadataSignals(metadata, [{ type: 'metadata', action: 'clear' }])); + expect(metadata).toEqual({}); + }); + + it('patches snapshot conversation metadata from signals', () => { + const patched = patchSnapshotFromSignals(baseSnapshot(), [ + { type: 'metadata', action: 'set', key: 'priority', value: 'high' }, + ]); + + expect(patched?.conversation.metadata).toEqual({ + ticketId: 'T-42', + priority: 'high', + }); + }); + + it('returns null when no metadata signals are present', () => { + const patched = patchSnapshotFromSignals(baseSnapshot(), [{ type: 'trigger', workflowId: 'wf-1' }]); + + expect(patched).toBeNull(); + }); + + it('marks snapshot conversation resolved', () => { + const patched = patchSnapshotResolved(baseSnapshot()); + + expect(patched.conversation.status).toBe('resolved'); + }); +}); diff --git a/packages/chat-adapter/src/snapshot-store.ts b/packages/chat-adapter/src/snapshot-store.ts new file mode 100644 index 00000000000..ecb8cbaf228 --- /dev/null +++ b/packages/chat-adapter/src/snapshot-store.ts @@ -0,0 +1,70 @@ +import type { AgentConversation, MetadataSignal, Signal, ThreadSnapshot } from './types.js'; + +/** Apply metadata signals to a conversation metadata object (immutable). */ +export function applyMetadataSignals( + metadata: Record, + signals: Signal[] +): { metadata: Record; changed: boolean } { + let next = metadata; + let changed = false; + + for (const signal of signals) { + if (signal.type !== 'metadata') { + continue; + } + + changed = true; + next = patchMetadata(next, signal); + } + + return { metadata: next, changed }; +} + +function patchMetadata(metadata: Record, signal: MetadataSignal): Record { + if (signal.action === 'clear') { + return {}; + } + + if (signal.action === 'delete') { + const next = { ...metadata }; + delete next[signal.key]; + + return next; + } + + return { ...metadata, [signal.key]: signal.value }; +} + +/** Return a snapshot with metadata/status updates applied locally after outbound signals. */ +export function patchSnapshotFromSignals(snapshot: ThreadSnapshot, signals: Signal[]): ThreadSnapshot | null { + const { metadata, changed: metadataChanged } = applyMetadataSignals(snapshot.conversation.metadata, signals); + if (!metadataChanged) { + return null; + } + + return { + ...snapshot, + conversation: patchConversationMetadata(snapshot.conversation, metadata), + }; +} + +/** Return a snapshot marked resolved after a successful resolve POST. */ +export function patchSnapshotResolved(snapshot: ThreadSnapshot): ThreadSnapshot { + return { + ...snapshot, + conversation: { + ...snapshot.conversation, + status: 'resolved', + }, + }; +} + +function patchConversationMetadata( + conversation: AgentConversation, + metadata: Record +): AgentConversation { + return { + ...conversation, + metadata, + }; +} diff --git a/packages/chat-adapter/src/thread-id.spec.ts b/packages/chat-adapter/src/thread-id.spec.ts new file mode 100644 index 00000000000..e531a9a9082 --- /dev/null +++ b/packages/chat-adapter/src/thread-id.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { channelIdFromThreadId, decodeThreadId, encodeThreadId, isDMThreadId } from './thread-id.js'; +import type { NovuThreadId } from './types.js'; + +describe('thread-id', () => { + const cases: NovuThreadId[] = [ + { platform: 'slack', integrationIdentifier: 'slack-prod', conversationId: '64f0a1b2c3d4e5f6', isDM: false }, + { platform: 'whatsapp', integrationIdentifier: 'wa-1', conversationId: 'abc123', isDM: true }, + { platform: 'email', integrationIdentifier: 'sendgrid:main', conversationId: 'id with spaces', isDM: false }, + ]; + + it('round-trips encode -> decode', () => { + for (const data of cases) { + expect(decodeThreadId(encodeThreadId(data))).toEqual(data); + } + }); + + it('derives channelId as novu::', () => { + const id = encodeThreadId(cases[0]!); + expect(channelIdFromThreadId(id)).toBe('novu:slack:slack-prod'); + }); + + it('reads isDM statelessly from the id', () => { + expect(isDMThreadId(encodeThreadId(cases[1]!))).toBe(true); + expect(isDMThreadId(encodeThreadId(cases[0]!))).toBe(false); + }); + + it('throws on malformed ids', () => { + for (const bad of ['', 'novu', 'novu:slack', 'email:foo:bar', 'novu:slack:int::0']) { + expect(() => decodeThreadId(bad)).toThrow(); + } + }); +}); diff --git a/packages/chat-adapter/src/thread-id.ts b/packages/chat-adapter/src/thread-id.ts new file mode 100644 index 00000000000..83aaedda16d --- /dev/null +++ b/packages/chat-adapter/src/thread-id.ts @@ -0,0 +1,46 @@ +import type { NovuThreadId } from './types.js'; + +/** + * Thread ids are packed as five colon-separated, URL-encoded segments: + * + * `novu::::` + * + * Packing platform + isDM into the id keeps `channelIdFromThreadId()` and + * `isDM()` fully stateless and synchronous — no state lookup required at routing + * time. The `dm` segment is `'1'` for direct messages and `'0'` otherwise. + */ +const PREFIX = 'novu'; + +export function encodeThreadId(data: NovuThreadId): string { + return [ + PREFIX, + encodeURIComponent(data.platform), + encodeURIComponent(data.integrationIdentifier), + encodeURIComponent(data.conversationId), + data.isDM ? '1' : '0', + ].join(':'); +} + +export function decodeThreadId(threadId: string): NovuThreadId { + const parts = threadId.split(':'); + if (parts.length !== 5 || parts[0] !== PREFIX || !parts[2] || !parts[3]) { + throw new Error(`Invalid Novu thread id format: ${threadId}`); + } + + return { + platform: decodeURIComponent(parts[1] ?? ''), + integrationIdentifier: decodeURIComponent(parts[2]), + conversationId: decodeURIComponent(parts[3]), + isDM: parts[4] === '1', + }; +} + +export function channelIdFromThreadId(threadId: string): string { + const { platform, integrationIdentifier } = decodeThreadId(threadId); + + return `${PREFIX}:${platform}:${integrationIdentifier}`; +} + +export function isDMThreadId(threadId: string): boolean { + return decodeThreadId(threadId).isDM; +} diff --git a/packages/chat-adapter/src/types.ts b/packages/chat-adapter/src/types.ts new file mode 100644 index 00000000000..026e3ec4317 --- /dev/null +++ b/packages/chat-adapter/src/types.ts @@ -0,0 +1,343 @@ +import type { Adapter, CardElement, Emoji, Thread } from 'chat'; + +// --------------------------------------------------------------------------- +// Adapter configuration +// --------------------------------------------------------------------------- + +export interface NovuAdapterConfig { + /** + * Novu secret API key. Sent as `Authorization: ApiKey ` on every reply + * POST to `apiBaseUrl/v1/agents/:id/reply`. Required. + */ + apiKey: string; + /** Agent identifier the bridge requests target and replies are posted to. Required. */ + agentIdentifier: string; + /** + * Shared secret used to verify the HMAC signature (`novu-signature` header) on + * every inbound bridge request. This is the Novu environment secret key that + * Novu signs `AgentBridgeRequest` payloads with. Required. + */ + bridgeSecret: string; + /** + * Base URL of the Novu API. The reply URL is *derived* from this + * (`/v1/agents//reply`) — the inbound request's + * `replyUrl` is deliberately ignored so the apiKey can never be exfiltrated to + * an attacker-controlled URL even if HMAC verification is misconfigured. + * + * @default 'https://api.novu.co' + */ + apiBaseUrl?: string; + /** + * When set, the adapter registers this URL as the agent's bridge endpoint at + * boot via `PUT /v1/agents/:id/bridge`. Omit to skip registration + * (e.g. when the bridge URL is configured in the Novu dashboard). + */ + bridgeUrl?: string; + /** + * Maximum age (ms) of a signed bridge request before it is rejected as stale. + * @default 300000 (5 minutes) + */ + maxSignatureAgeMs?: number; + /** Injected fetch implementation (defaults to global `fetch`). Primarily for tests. */ + fetch?: typeof fetch; +} + +// --------------------------------------------------------------------------- +// Bridge wire contract (mirrors @novu/framework AgentBridgeRequest) +// --------------------------------------------------------------------------- + +export enum AgentEvent { + ON_MESSAGE = 'onMessage', + ON_ACTION = 'onAction', + ON_RESOLVE = 'onResolve', + ON_REACTION = 'onReaction', +} + +export interface AgentMessageAuthor { + userId: string; + fullName: string; + userName: string; + isBot: boolean | 'unknown'; +} + +export interface AgentAttachment { + type: string; + url?: string; + name?: string; + mimeType?: string; + size?: number; +} + +export interface AgentMessage { + text: string; + platformMessageId: string; + author: AgentMessageAuthor; + timestamp: string; + attachments?: AgentAttachment[]; +} + +export interface AgentConversation { + identifier: string; + status: string; + metadata: Record; + messageCount: number; + createdAt: string; + lastActivityAt: string; +} + +export interface AgentSubscriber { + subscriberId: string; + firstName?: string; + lastName?: string; + email?: string; + phone?: string; + avatar?: string; + locale?: string; + data?: Record; +} + +export interface AgentHistoryEntry { + role: string; + type: string; + content: string; + richContent?: Record; + senderName?: string; + signalData?: { type: string; payload?: Record }; + createdAt: string; +} + +export interface AgentAction { + id: string; + value?: string; + sourceMessageId?: string; +} + +export interface AgentReaction { + messageId: string; + emoji: { name: string }; + added: boolean; + message: AgentMessage | null; +} + +/** Resolved inbound email domain metadata (present when `platform === 'email'`). */ +export interface AgentEmailDomainContext { + id: string; + name: string; + data?: Record; +} + +/** Resolved inbound email route metadata (present when `platform === 'email'`). */ +export interface AgentEmailRouteContext { + address: string; + data?: Record; +} + +/** Resolved inbound email envelope (present when `platform === 'email'`). */ +export interface AgentEmailContext { + domain?: AgentEmailDomainContext; + route?: AgentEmailRouteContext; + /** Platform-native Message-ID of the message that started this email thread. */ + rootMessageId?: string; +} + +export interface AgentPlatformContext { + threadId: string; + channelId: string; + isDM: boolean; + message?: unknown; + email?: AgentEmailContext; +} + +/** Workflow trigger recipient (mirrors @novu/shared TriggerRecipientsPayload). */ +export type TriggerRecipientSubscriber = string | { subscriberId: string }; + +export type TriggerRecipientTopic = { type: string; topicKey: string }; + +export type TriggerRecipientsPayload = + | TriggerRecipientSubscriber + | TriggerRecipientTopic + | Array; + +export interface AgentBridgeRequest { + version: number; + timestamp: string; + deliveryId: string; + event: string; + agentId: string; + replyUrl: string; + conversationId: string; + integrationIdentifier: string; + action: AgentAction | null; + message: AgentMessage | null; + reaction: AgentReaction | null; + conversation: AgentConversation; + subscriber: AgentSubscriber | null; + history: AgentHistoryEntry[]; + platform: string; + platformContext: AgentPlatformContext; +} + +// --------------------------------------------------------------------------- +// Reply wire contract (mirrors @novu/framework AgentReplyPayload) +// --------------------------------------------------------------------------- + +export interface ReplyFileRef { + filename: string; + mimeType?: string; + data?: string; + url?: string; +} + +export interface ReplyContent { + markdown?: string; + card?: CardElement; + files?: ReplyFileRef[]; +} + +export interface EditPayload { + messageId: string; + content: ReplyContent; +} + +export interface AddReactionPayload { + messageId: string; + emojiName: Emoji | string; +} + +export type MetadataSignal = + | { type: 'metadata'; action: 'set'; key: string; value: unknown } + | { type: 'metadata'; action: 'delete'; key: string } + | { type: 'metadata'; action: 'clear' }; + +export type TriggerSignal = { + type: 'trigger'; + workflowId: string; + to?: TriggerRecipientsPayload; + payload?: Record; +}; + +export type Signal = MetadataSignal | TriggerSignal; + +export interface AgentReplyPayload { + conversationId: string; + integrationIdentifier: string; + reply?: ReplyContent; + edit?: EditPayload; + resolve?: { summary?: string }; + signals?: Signal[]; + addReactions?: AddReactionPayload[]; +} + +/** Shape returned by `/agents/:id/reply` when a reply or edit was delivered. */ +export interface SentMessageInfo { + messageId: string; + platformThreadId: string; +} + +// --------------------------------------------------------------------------- +// Adapter-internal types +// --------------------------------------------------------------------------- + +/** Decoded thread identity. Packed into / out of the opaque thread id string. */ +export interface NovuThreadId { + platform: string; + integrationIdentifier: string; + conversationId: string; + isDM: boolean; +} + +/** Novu history fields preserved on messages reconstructed from bridge history. */ +export interface NovuHistoryFields { + role: string; + type: string; + richContent?: Record; + signalData?: { type: string; payload?: Record }; +} + +/** Platform-native raw message carried on `RawMessage.raw` / `platformContext.message`. */ +export interface NovuRawMessage { + id: string; + text: string; + author: AgentMessageAuthor; + timestamp: string; + attachments?: AgentAttachment[]; + conversationId: string; + integrationIdentifier: string; + platform: string; + /** Set when this message was built from Novu conversation history. */ + history?: NovuHistoryFields; +} + +/** Cached snapshot of the latest bridge request for a thread (for fetchThread/fetchMessages). */ +export interface ThreadSnapshot { + history: AgentHistoryEntry[]; + conversation: AgentConversation; + subscriber: AgentSubscriber | null; + platform: string; + platformContext: AgentPlatformContext; +} + +/** Opt-in, Novu-only context surfaced via `getNovuContext(thread)`. */ +export interface NovuContext { + /** Novu platform this thread arrived on (e.g. `'slack'`, `'whatsapp'`). */ + readonly platform: string; + /** + * The Novu subscriber for this conversation, with the full rich profile + * (`email`, `phone`, `avatar`, `locale`, custom `data`). Resolved from the + * cached bridge snapshot for the thread; `null` if no snapshot is cached yet + * (e.g. before the first inbound message) or the conversation has no + * subscriber. For just the portable identity fields, prefer the SDK-native + * `adapter.getUser(userId)` / `message.author`. + */ + getSubscriber(): Promise; + /** + * Live Novu conversation state for this thread (status, metadata, messageCount, + * timestamps). Resolved from the cached bridge snapshot. + */ + getConversation(): Promise; + /** + * Full Novu conversation history as delivered on the bridge — the best source + * for LLM context (`role`, `content`, `richContent`, `signalData`). Prefer this + * over iterating `fetchMessages` when you need the canonical Novu transcript. + */ + getHistory(): Promise; + /** + * Inbound email routing metadata when `platform === 'email'` (domain, route, + * rootMessageId for threading). `null` on other platforms or when absent. + */ + getEmailContext(): Promise; + /** Read a key from the current `conversation.metadata` snapshot. */ + getMetadata(key: string): Promise; + /** Trigger a Novu workflow for this conversation's subscriber (or explicit recipients). */ + trigger( + workflowId: string, + opts?: { to?: TriggerRecipientsPayload; payload?: Record } + ): Promise; + /** Persist a key/value into `conversation.metadata`. */ + setMetadata(key: string, value: unknown): Promise; + /** Delete a key from `conversation.metadata`. */ + deleteMetadata(key: string): Promise; + /** Reset `conversation.metadata` to `{}`. */ + clearMetadata(): Promise; + /** Mark the conversation resolved, with an optional summary. */ + resolve(summary?: string): Promise; +} + +/** + * Consumer-facing adapter type — widened to the base `Adapter` so instances are + * assignable to `new Chat({ adapters: { novu } })` without generic variance errors. + */ +export type NovuAdapter = Adapter; + +/** Adapter with Novu-specific thread/message generics (implementation / advanced use). */ +export type NovuTypedAdapter = Adapter; + +/** Subset of the adapter surface `getNovuContext` needs — avoids a circular import. */ +export interface NovuContextSource { + emitSignals(threadId: string, signals: Signal[]): Promise; + emitResolve(threadId: string, summary?: string): Promise; + decodeThreadId(threadId: string): NovuThreadId; + getSnapshot(threadId: string): Promise; +} + +export type AdapterThread = Thread; diff --git a/packages/chat-adapter/src/webhook-handler.ts b/packages/chat-adapter/src/webhook-handler.ts new file mode 100644 index 00000000000..9282ddd26e6 --- /dev/null +++ b/packages/chat-adapter/src/webhook-handler.ts @@ -0,0 +1,41 @@ +import { getSignatureHeader, verifyNovuSignature } from './signature.js'; +import type { AgentBridgeRequest } from './types.js'; + +export interface ParseResult { + request: AgentBridgeRequest | null; + status: number; +} + +/** + * Verifies the HMAC over the *raw* request body, then parses the + * `AgentBridgeRequest`. Reading the body once via `request.text()` is required — + * the signature is computed over those exact bytes. + */ +export class WebhookHandler { + constructor( + private readonly bridgeSecret: string, + private readonly maxAgeMs?: number + ) {} + + async parseAndVerify(request: Request): Promise { + const signature = getSignatureHeader(request); + const rawBody = await request.text(); + + if (!verifyNovuSignature(signature, rawBody, this.bridgeSecret, { maxAgeMs: this.maxAgeMs })) { + return { request: null, status: 401 }; + } + + let parsed: AgentBridgeRequest; + try { + parsed = JSON.parse(rawBody) as AgentBridgeRequest; + } catch { + return { request: null, status: 400 }; + } + + if (!parsed || typeof parsed !== 'object' || !parsed.conversationId || !parsed.event) { + return { request: null, status: 400 }; + } + + return { request: parsed, status: 200 }; + } +} diff --git a/packages/chat-adapter/tsconfig.json b/packages/chat-adapter/tsconfig.json new file mode 100644 index 00000000000..f82e7afb97d --- /dev/null +++ b/packages/chat-adapter/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "composite": true, + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "isolatedModules": true, + "jsx": "react-jsx", + "lib": ["ES2021", "DOM", "DOM.Iterable"], + "module": "nodenext", + "moduleDetection": "force", + "moduleResolution": "nodenext", + "noImplicitOverride": true, + "noUncheckedIndexedAccess": true, + "outDir": "./dist", + "resolveJsonModule": true, + "rootDir": "./src", + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "strictPropertyInitialization": false, + "target": "ES2021", + "verbatimModuleSyntax": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/node_modules/**", "**/*.spec.ts"] +} diff --git a/packages/chat-adapter/vitest.config.ts b/packages/chat-adapter/vitest.config.ts new file mode 100644 index 00000000000..9c6bf57f89d --- /dev/null +++ b/packages/chat-adapter/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.spec.ts'], + environment: 'node', + }, +}); diff --git a/playground/nextjs/.env.example b/playground/nextjs/.env.example index 2877d65d677..978f4fa12b0 100644 --- a/playground/nextjs/.env.example +++ b/playground/nextjs/.env.example @@ -15,6 +15,15 @@ NOVU_SECRET_KEY= NOVU_SUBSCRIBER_ID= NOVU_HITL_WORKFLOW_ID=refund-approval +# Novu Chat-adapter bridge (/api/novu-agent, test UI at /novu-agent) +# Reuses NOVU_SECRET_KEY above as both the reply apiKey and the bridge HMAC secret. +NOVU_AGENT_IDENTIFIER= +# Optional — defaults to https://api.novu.co (use https://dev.api.novu.co for dev cloud) +NOVU_API_BASE_URL= +# Optional — public URL of this bridge route; when set it's registered on boot +# (use an ngrok/tunnel URL for local dev, e.g. https://.ngrok-free.dev/api/novu-agent) +NOVU_BRIDGE_URL= + # Slack Connect Chat demo NEXT_PUBLIC_NOVU_SLACK_INTEGRATION_IDENTIFIER= NEXT_PUBLIC_SLACK_USER_ID= diff --git a/playground/nextjs/package.json b/playground/nextjs/package.json index 8c44d8d78b8..1914e35ead1 100644 --- a/playground/nextjs/package.json +++ b/playground/nextjs/package.json @@ -14,9 +14,12 @@ }, "dependencies": { "@ai-sdk/openai": "^3.0.0", + "@chat-adapter/state-memory": "4.30.0", + "@novu/chat-sdk-adapter": "workspace:*", "@novu/api": "workspace:*", "@ai-sdk/react": "^3.0.0", "@clerk/react": "^6.6.6", + "chat": "4.30.0", "@novu/agent-toolkit": "workspace:*", "@novu/nextjs": "workspace:*", "@novu/shared": "workspace:*", diff --git a/playground/nextjs/src/app/api/novu-agent/agent.ts b/playground/nextjs/src/app/api/novu-agent/agent.ts new file mode 100644 index 00000000000..59c6dc0ec32 --- /dev/null +++ b/playground/nextjs/src/app/api/novu-agent/agent.ts @@ -0,0 +1,163 @@ +import { createMemoryState } from '@chat-adapter/state-memory'; +import { createNovuAdapter, getNovuContext } from '@novu/chat-sdk-adapter'; +import { + Actions, + type Adapter, + Button, + Card, + type CardElement, + CardText, + Chat, + Divider, + Field, + Fields, + LinkButton, + Section, + type StateAdapter, +} from 'chat'; + +/** + * A rich card built with the chat-sdk programmatic builders (no JSX needed). + * Posting this via `thread.post(card)` exercises the adapter's card → reply + * conversion (`toReplyContent` → `{ card }`), which is what every channel + * (Slack, Teams, …) renders natively. + */ +function buildDemoCard(platform: string): CardElement { + return Card({ + title: '🎴 Card from chat-sdk', + subtitle: `Posted via @novu/chat-sdk-adapter on ${platform}`, + children: [ + CardText('This card was posted with `thread.post(Card(...))` and normalized into an agent reply payload.'), + Divider(), + Fields([ + Field({ label: 'Platform', value: platform }), + Field({ label: 'Source', value: 'chat-sdk' }), + ]), + Section([CardText('Buttons below emit `onAction` callbacks back through the bridge.')]), + Actions([ + Button({ id: 'card-approve', label: 'Approve', style: 'primary', value: 'approved' }), + Button({ id: 'card-dismiss', label: 'Dismiss', style: 'danger', value: 'dismissed' }), + LinkButton({ url: 'https://novu.co', label: 'Open Novu' }), + ]), + ], + }); +} + +/** + * Shared agent definition for the Novu Chat-adapter playground. + * + * The same `registerHandlers` is used by: + * - the live bridge endpoint (`/api/novu-agent`), which Novu POSTs real + * `AgentBridgeRequest`s to, and + * - the local simulator (`/api/novu-agent/simulate`), which feeds a signed + * sample request through a throwaway instance so you can test routing and + * replies in the browser without any channel or Novu credentials. + */ +export function registerHandlers(chat: Chat): void { + // First message in a brand-new conversation. For DMs, Chat SDK routes here only when + // no onDirectMessage handler is registered (see chat-sdk DirectMessageHandler docs). + chat.onNewMention(async (thread, message) => { + if (message.text.trim().toLowerCase() === 'card') { + await thread.post(buildDemoCard(getNovuContext(thread).platform)); + + return; + } + + if (thread.isDM) { + await thread.post(`👋 Hello! (DM) You said: "${message.text}".`); + + return; + } + + await thread.post(`👋 Hi! You said: "${message.text}". I'll remember this conversation.`); + }); + + // Every subsequent message in an ongoing conversation (channels and DMs). The Novu + // adapter pre-subscribes when messageCount > 1 or history is non-empty. + chat.onSubscribedMessage(async (thread, message) => { + console.log('onSubscribedMessage', JSON.stringify(thread, null, 2), JSON.stringify(message, null, 2)); + const user = await thread.adapter.getUser?.(message.author.userId); + console.log('user', JSON.stringify(user, null, 2)); + const novu = getNovuContext(thread); + + // Demonstrate the opt-in, Novu-only escape hatch. + if (message.text.trim().toLowerCase() === 'resolve') { + await novu.resolve('Resolved from the playground agent.'); + await thread.post('✅ Marked this conversation as resolved.'); + + return; + } + + // Post a rich interactive card and let the adapter normalize it for the channel. + if (message.text.trim().toLowerCase() === 'card') { + await thread.post(buildDemoCard(novu.platform)); + + return; + } + + // Demonstrate subscriber access: the full Novu profile via the escape hatch + // and the portable SDK-native identity via getUser. + if (message.text.trim().toLowerCase() === 'whoami') { + const subscriber = await novu.getSubscriber(); + const user = await thread.adapter.getUser?.(message.author.userId); + await thread.post( + `👤 subscriber: ${subscriber?.subscriberId ?? 'unknown'} (${subscriber?.email ?? 'no email'})` + + (user ? ` · userInfo: ${user.fullName}` : '') + ); + + return; + } + + await thread.post(`echo (${novu.platform}): ${message.text}`); + }); + + // Button clicks from interactive cards. + chat.onAction(async (event) => { + await event.thread?.post(`You clicked **${event.actionId}**${event.value ? ` (value: ${event.value})` : ''}.`); + }); + + // Emoji reactions. + chat.onReaction(async (event) => { + if (!event.added) return; + await event.thread.post(`Thanks for the ${event.emoji} reaction!`); + }); +} + +let agentPromise: Promise<{ chat: Chat; novu: Adapter }> | null = null; + +/** + * Build (once) and return the live bridge agent. Requires `NOVU_SECRET_KEY` and + * `NOVU_AGENT_IDENTIFIER`. Uses the zero-deps in-memory state adapter — fine for + * a single playground instance; swap in a shared state adapter for multi-instance. + */ +export function getNovuAgent(): Promise<{ chat: Chat; novu: Adapter }> { + if (!agentPromise) { + agentPromise = (async () => { + const apiKey = process.env.NOVU_SECRET_KEY; + const agentIdentifier = process.env.NOVU_AGENT_IDENTIFIER; + if (!apiKey) throw new Error('NOVU_SECRET_KEY is not set'); + if (!agentIdentifier) throw new Error('NOVU_AGENT_IDENTIFIER is not set'); + + const novu = createNovuAdapter({ + apiKey, + agentIdentifier, + bridgeSecret: apiKey, + ...(process.env.NOVU_API_BASE_URL ? { apiBaseUrl: process.env.NOVU_API_BASE_URL } : {}), + ...(process.env.NOVU_BRIDGE_URL ? { bridgeUrl: process.env.NOVU_BRIDGE_URL } : {}), + }); + + const chat = new Chat({ + userName: 'novu-playground-agent', + adapters: { novu: novu as unknown as Adapter }, + state: createMemoryState() as unknown as StateAdapter, + }); + + registerHandlers(chat); + await chat.initialize(); + + return { chat, novu: novu as unknown as Adapter }; + })(); + } + + return agentPromise; +} diff --git a/playground/nextjs/src/app/api/novu-agent/route.ts b/playground/nextjs/src/app/api/novu-agent/route.ts new file mode 100644 index 00000000000..8a00262988d --- /dev/null +++ b/playground/nextjs/src/app/api/novu-agent/route.ts @@ -0,0 +1,34 @@ +import { getNovuAgent } from './agent'; + +// node:crypto + dynamic import('chat') require the Node.js runtime (not edge). +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * Live bridge endpoint. Point a Novu agent's bridge URL (or `devBridgeUrl` via a + * tunnel) at this route: `https:///api/novu-agent`. Novu POSTs a signed + * `AgentBridgeRequest` here for every inbound message/action/reaction across all + * connected channels; the adapter verifies the HMAC and dispatches to the + * handlers in `agent.ts`. + */ +export async function POST(req: Request): Promise { + try { + const { novu } = await getNovuAgent(); + + return await novu.handleWebhook(req); + } catch (err) { + return Response.json({ error: err instanceof Error ? err.message : 'Bridge error' }, { status: 500 }); + } +} + +export async function GET(): Promise { + const configured = Boolean(process.env.NOVU_SECRET_KEY && process.env.NOVU_AGENT_IDENTIFIER); + + return Response.json({ + ok: true, + configured, + hint: configured + ? 'Point your Novu agent bridge URL at POST /api/novu-agent' + : 'Set NOVU_SECRET_KEY and NOVU_AGENT_IDENTIFIER to enable the live bridge', + }); +} diff --git a/playground/nextjs/src/app/api/novu-agent/simulate/route.ts b/playground/nextjs/src/app/api/novu-agent/simulate/route.ts new file mode 100644 index 00000000000..aa6239722a0 --- /dev/null +++ b/playground/nextjs/src/app/api/novu-agent/simulate/route.ts @@ -0,0 +1,135 @@ +import { createHmac } from 'node:crypto'; +import { createMemoryState } from '@chat-adapter/state-memory'; +import { type AgentBridgeRequest, createNovuAdapter } from '@novu/chat-sdk-adapter'; +import { type Adapter, Chat, type StateAdapter } from 'chat'; +import { registerHandlers } from '../agent'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const SIM_SECRET = process.env.NOVU_SECRET_KEY ?? 'playground-dev-secret'; + +function sign(body: string, secret: string): string { + const ts = Date.now(); + const hmac = createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex'); + + return `t=${ts},v1=${hmac}`; +} + +interface SimulateBody { + text?: string; + event?: 'onMessage' | 'onAction' | 'onReaction' | 'onResolve'; + platform?: string; + isDM?: boolean; + /** messageCount > 1 (or non-empty history) routes to onSubscribedMessage. */ + ongoing?: boolean; + emoji?: string; + actionId?: string; +} + +function buildBridge(input: SimulateBody): AgentBridgeRequest { + const ongoing = input.ongoing ?? true; + const platform = input.platform ?? 'slack'; + const now = new Date().toISOString(); + const text = input.text ?? 'hello from the simulator'; + + return { + version: 1, + timestamp: now, + deliveryId: `sim-${Date.now()}-${Math.random().toString(36).slice(2)}`, + event: input.event ?? 'onMessage', + agentId: process.env.NOVU_AGENT_IDENTIFIER ?? 'playground-agent', + replyUrl: 'https://api.novu.co/v1/agents/playground-agent/reply', + conversationId: 'sim-conversation', + integrationIdentifier: `${platform}-playground`, + action: + input.event === 'onAction' + ? { id: input.actionId ?? 'approve', value: 'sim-value', sourceMessageId: 'sim-msg' } + : null, + message: + input.event === 'onAction' || input.event === 'onReaction' + ? null + : { + text, + platformMessageId: `sim-msg-${Date.now()}`, + author: { userId: 'sim-user', userName: 'tester', fullName: 'Sim Tester', isBot: false }, + timestamp: now, + }, + reaction: + input.event === 'onReaction' + ? { messageId: 'sim-msg', emoji: { name: input.emoji ?? 'thumbs_up' }, added: true, message: null } + : null, + conversation: { + identifier: 'sim-conversation', + status: 'open', + metadata: {}, + messageCount: ongoing ? 3 : 1, + createdAt: now, + lastActivityAt: now, + }, + subscriber: { subscriberId: 'sim-subscriber', firstName: 'Sim', email: 'sim@example.com' }, + history: ongoing ? [{ role: 'user', type: 'text', content: 'previous message', createdAt: now }] : [], + platform, + platformContext: { threadId: 'sim-thread', channelId: 'sim-channel', isDM: input.isDM ?? false }, + }; +} + +/** + * Local, credential-free echo test. Crafts a signed `AgentBridgeRequest`, feeds + * it through a throwaway Novu adapter whose reply POSTs are captured instead of + * sent to Novu, and returns whatever the agent would have replied. Lets you + * exercise the full inbound→handler→reply path from the browser test page. + */ +export async function POST(req: Request): Promise { + const input = (await req.json().catch(() => ({}))) as SimulateBody; + + const replies: Array<{ url: string; payload: unknown }> = []; + const capturingFetch: typeof fetch = async (url, init) => { + replies.push({ + url: String(url), + payload: init?.body ? JSON.parse(init.body as string) : null, + }); + + return new Response(JSON.stringify({ messageId: 'sim-reply', platformThreadId: 'sim-thread' }), { + status: 200, + }); + }; + + const novu = createNovuAdapter({ + apiKey: 'sim-api-key', + agentIdentifier: process.env.NOVU_AGENT_IDENTIFIER ?? 'playground-agent', + bridgeSecret: SIM_SECRET, + fetch: capturingFetch, + }); + const chat = new Chat({ + userName: 'novu-playground-sim', + adapters: { novu: novu as unknown as Adapter }, + state: createMemoryState() as unknown as StateAdapter, + }); + registerHandlers(chat); + await chat.initialize(); + + const bridge = buildBridge(input); + const body = JSON.stringify(bridge); + const request = new Request('http://local/simulate', { + method: 'POST', + headers: { 'content-type': 'application/json', 'novu-signature': sign(body, SIM_SECRET) }, + body, + }); + + const response = await novu.handleWebhook(request); + + return Response.json({ + status: response.status, + routedTo: routeLabel(bridge), + replies, + }); +} + +function routeLabel(bridge: AgentBridgeRequest): string { + if (bridge.event === 'onAction') return 'onAction'; + if (bridge.event === 'onReaction') return 'onReaction'; + if (bridge.conversation.messageCount > 1 || bridge.history.length > 0) return 'onSubscribedMessage'; + + return 'onNewMention'; +} diff --git a/playground/nextjs/src/app/novu-agent/page.tsx b/playground/nextjs/src/app/novu-agent/page.tsx new file mode 100644 index 00000000000..1560e389aad --- /dev/null +++ b/playground/nextjs/src/app/novu-agent/page.tsx @@ -0,0 +1,196 @@ +'use client'; + +import { useState } from 'react'; + +type SimEvent = 'onMessage' | 'onAction' | 'onReaction'; + +interface SimResult { + status: number; + routedTo: string; + replies: Array<{ url: string; payload: unknown }>; +} + +export default function NovuAgentPlayground() { + const [text, setText] = useState('hello there'); + const [platform, setPlatform] = useState('slack'); + const [event, setEvent] = useState('onMessage'); + const [ongoing, setOngoing] = useState(true); + const [isDM, setIsDM] = useState(false); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + async function run(overrides?: { text?: string; event?: SimEvent }) { + setLoading(true); + setError(null); + setResult(null); + try { + const res = await fetch('/api/novu-agent/simulate', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + text: overrides?.text ?? text, + platform, + event: overrides?.event ?? event, + ongoing, + isDM, + }), + }); + const data = (await res.json()) as SimResult; + setResult(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Request failed'); + } finally { + setLoading(false); + } + } + + function hasCardReply(replies: SimResult['replies']): boolean { + return replies.some((r) => { + const payload = r.payload as { reply?: { card?: unknown } } | null; + + return Boolean(payload?.reply?.card); + }); + } + + return ( +
+

Novu Chat-adapter playground

+

+ Craft a signed AgentBridgeRequest and run it through the real @novu/chat-sdk-adapter adapter + locally. Reply POSTs are captured instead of sent to Novu — no credentials needed. +

+

+ Tip: send the message card (or click Send a card reply) to have the agent post a + chat-sdk Card; whoami echoes the resolved subscriber. +

+ +
+ + +
+ + + +
+ +
+ + +
+ +
+ + +
+
+ + {error &&
{error}
} + + {result && ( +
+

+ HTTP status: {result.status}  ·  Routed to:{' '} + {result.routedTo} +

+ {hasCardReply(result.replies) && ( +

+ ✅ Card reply captured — the adapter normalized the chat-sdk Card into reply.card. +

+ )} +

Captured replies ({result.replies.length}):

+
{JSON.stringify(result.replies, null, 2)}
+
+ )} + +
+

+ Live bridge endpoint: POST /api/novu-agent. Set NOVU_SECRET_KEY and{' '} + NOVU_AGENT_IDENTIFIER, then point your Novu agent's bridge URL at it to test real channels. +

+
+ ); +} + +const primaryButtonStyle: React.CSSProperties = { + padding: '10px 16px', + background: '#000', + color: '#fff', + borderRadius: 8, + border: 'none', + cursor: 'pointer', + width: 'fit-content', +}; +const secondaryButtonStyle: React.CSSProperties = { + padding: '10px 16px', + background: '#fff', + color: '#000', + borderRadius: 8, + border: '1px solid #000', + cursor: 'pointer', + width: 'fit-content', +}; +const labelStyle: React.CSSProperties = { display: 'flex', flexDirection: 'column', gap: 4, fontSize: 14, fontWeight: 600 }; +const inputStyle: React.CSSProperties = { + padding: '8px 10px', + border: '1px solid #ccc', + borderRadius: 8, + fontSize: 14, + fontWeight: 400, +}; +const preStyle: React.CSSProperties = { + marginTop: 8, + padding: 12, + background: '#f6f6f6', + borderRadius: 8, + overflowX: 'auto', + fontSize: 13, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a19e2042362..df79c101387 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3394,6 +3394,34 @@ importers: specifier: 5.6.2 version: 5.6.2 + packages/chat-adapter: + dependencies: + '@chat-adapter/shared': + specifier: 4.30.0 + version: 4.30.0(zod@4.3.6) + chat: + specifier: ^4.30.0 + version: 4.30.0(zod@4.3.6) + react: + specifier: '>=18.0.0 || >=19.0.0' + version: 19.2.3 + devDependencies: + '@chat-adapter/state-memory': + specifier: 4.30.0 + version: 4.30.0(zod@4.3.6) + '@types/react': + specifier: ^19.0.0 + version: 19.2.8 + rimraf: + specifier: ~3.0.2 + version: 3.0.2 + typescript: + specifier: 5.6.2 + version: 5.6.2 + vitest: + specifier: ^4.1.0 + version: 4.1.7(@edge-runtime/vm@3.0.3)(@opentelemetry/api@1.9.0)(@types/node@22.15.13)(happy-dom@20.8.9)(jsdom@20.0.3)(vite@6.4.2(@types/node@22.15.13)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.31.6)(tsx@4.21.0)(yaml@2.8.3)) + packages/chat-adapter-email: dependencies: '@novu/shared': @@ -4286,6 +4314,9 @@ importers: '@ai-sdk/react': specifier: ^3.0.0 version: 3.0.51(react@18.3.1)(zod@4.3.5) + '@chat-adapter/state-memory': + specifier: 4.30.0 + version: 4.30.0(ai@6.0.50(zod@4.3.5))(zod@4.3.5) '@clerk/react': specifier: ^6.6.6 version: 6.6.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -4295,6 +4326,9 @@ importers: '@novu/api': specifier: workspace:* version: link:../../libs/internal-sdk + '@novu/chat-sdk-adapter': + specifier: workspace:* + version: link:../../packages/chat-adapter '@novu/nextjs': specifier: workspace:* version: link:../../packages/nextjs @@ -4382,6 +4416,9 @@ importers: ansi-to-react: specifier: ^6.2.6 version: 6.2.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + chat: + specifier: 4.30.0 + version: 4.30.0(ai@6.0.50(zod@4.3.5))(zod@4.3.5) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -6507,6 +6544,10 @@ packages: resolution: {integrity: sha512-IJSNT3cx+QPRhvPSEDO8WfZBW/6L1SNheWO9nXkPQ43nalXagioSqJrpExqnQrfyv2CYtG4Hen29ORwRdbOSTw==} engines: {node: '>=20'} + '@chat-adapter/state-memory@4.30.0': + resolution: {integrity: sha512-huYnGBKBiY6s/GaYgtAmr0zM6ihvGl8/NOEV9oqY89jr6Zdnm/ZXdpXvDKdXoaxmDXCiZr2U54DbSDzpvGQ6fA==} + engines: {node: '>=20'} + '@chat-adapter/teams@4.30.0': resolution: {integrity: sha512-5hm20xnePw8CeeGFAXIPI5D7gNL6TfwCE0sAhJIBwlH8H4SztxJWDvpp8AiHokYjck3ejtaHqXLiGrauUzAi0A==} engines: {node: '>=20'} @@ -31662,7 +31703,7 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.39.0 '@standard-schema/spec': 1.1.0 - better-call: 1.3.2(zod@4.3.5) + better-call: 1.3.2(zod@4.3.6) jose: 6.1.3 kysely: 0.28.17 nanostores: 1.2.0 @@ -31793,6 +31834,14 @@ snapshots: - supports-color - zod + '@chat-adapter/shared@4.30.0(zod@4.3.6)': + dependencies: + chat: 4.30.0(zod@4.3.6) + transitivePeerDependencies: + - ai + - supports-color + - zod + '@chat-adapter/slack@4.30.0(zod@3.25.20)': dependencies: '@chat-adapter/shared': 4.30.0(zod@3.25.20) @@ -31816,6 +31865,22 @@ snapshots: - supports-color - zod + '@chat-adapter/state-memory@4.30.0(ai@6.0.50(zod@4.3.5))(zod@4.3.5)': + dependencies: + chat: 4.30.0(ai@6.0.50(zod@4.3.5))(zod@4.3.5) + transitivePeerDependencies: + - ai + - supports-color + - zod + + '@chat-adapter/state-memory@4.30.0(zod@4.3.6)': + dependencies: + chat: 4.30.0(zod@4.3.6) + transitivePeerDependencies: + - ai + - supports-color + - zod + '@chat-adapter/teams@4.30.0(zod@3.25.20)': dependencies: '@chat-adapter/shared': 4.30.0(zod@3.25.20) @@ -45483,7 +45548,7 @@ snapshots: '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.1.1 '@noble/hashes': 2.0.1 - better-call: 1.3.2(zod@4.3.5) + better-call: 1.3.2(zod@4.3.6) defu: 6.1.6 jose: 6.1.3 kysely: 0.28.17 @@ -45502,15 +45567,6 @@ snapshots: - '@cloudflare/workers-types' - '@opentelemetry/api' - better-call@1.3.2(zod@4.3.5): - dependencies: - '@better-auth/utils': 0.3.1 - '@better-fetch/fetch': 1.1.21 - rou3: 0.7.12 - set-cookie-parser: 3.1.0 - optionalDependencies: - zod: 4.3.5 - better-call@1.3.2(zod@4.3.6): dependencies: '@better-auth/utils': 0.3.1 @@ -45940,6 +45996,21 @@ snapshots: chardet@2.1.1: {} + chat@4.30.0(ai@6.0.50(zod@4.3.5))(zod@4.3.5): + dependencies: + '@workflow/serde': 4.1.0-beta.2 + mdast-util-to-string: 4.0.0 + remark-gfm: 4.0.1 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + remend: 1.3.0 + unified: 11.0.5 + optionalDependencies: + ai: 6.0.50(zod@4.3.5) + zod: 4.3.5 + transitivePeerDependencies: + - supports-color + chat@4.30.0(zod@3.25.20): dependencies: '@workflow/serde': 4.1.0-beta.2