diff --git a/docker-compose.cloud.yml b/docker-compose.cloud.yml index 0f9bc67b0..e6c845c7d 100644 --- a/docker-compose.cloud.yml +++ b/docker-compose.cloud.yml @@ -167,6 +167,14 @@ x-backend: &backend # Where replies go when SMTP_FROM is a noreply address. - SMTP_REPLY_TO=${SMTP_REPLY_TO:-} - SMTP_SECURE=${SMTP_SECURE:-false} + # Live support chat (Libredesk). Unset = no widget, no triage webhook. + - SUPPORT_CHAT_URL=${SUPPORT_CHAT_URL:-} + - SUPPORT_CHAT_INBOX_ID=${SUPPORT_CHAT_INBOX_ID:-} + - SUPPORT_CHAT_SECRET=${SUPPORT_CHAT_SECRET:-} + - LIBREDESK_API_KEY=${LIBREDESK_API_KEY:-} + - LIBREDESK_API_SECRET=${LIBREDESK_API_SECRET:-} + - LIBREDESK_WEBHOOK_SECRET=${LIBREDESK_WEBHOOK_SECRET:-} + - LIBREDESK_AI_ASSISTANT_USER_ID=${LIBREDESK_AI_ASSISTANT_USER_ID:-} volumes: # One heap snapshot per process, written by the heap guard when the heap # crosses HEAP_SNAPSHOT_PERCENT. A volume so it outlives the container diff --git a/packages/backend/src/app.module.ts b/packages/backend/src/app.module.ts index 17f8a2112..b25ccb186 100644 --- a/packages/backend/src/app.module.ts +++ b/packages/backend/src/app.module.ts @@ -44,6 +44,8 @@ import { OrganizationsModule } from './organizations/organizations.module'; import { PublicStatsModule } from './public-stats/public-stats.module'; import { CloudModule } from './ee/cloud/cloud.module'; import { LicensingModule } from './ee/licensing/licensing.module'; +import { SupportModule } from './ee/support/support.module'; +import { supportModuleEnabled } from './ee/support/support-config'; import { getRequiredSecret } from './common/secrets.util'; import { AppLoggerModule } from './common/logger.module'; import { SentryContextInterceptor } from './common/sentry-context.interceptor'; @@ -51,6 +53,9 @@ import { SentryContextInterceptor } from './common/sentry-context.interceptor'; // Determine deployment and auth mode from env const useCloud = process.env.DEPLOYMENT_MODE === 'cloud'; const cloudImports = useCloud ? [CloudModule] : []; +// Support chat of the instances we operate (Cloud + dedicated enterprise +// instances); off unless SUPPORT_CHAT_* / LIBREDESK_* are set. +const supportImports = supportModuleEnabled() ? [SupportModule] : []; // Determine auth mode from env const authMode = process.env.MCP_AUTH_MODE || 'none'; @@ -175,6 +180,7 @@ if (useOAuth) { // Cloud-specific modules (conditionally loaded) ...cloudImports, + ...supportImports, ], providers: [ // Reports exceptions Nest turns into a 500 response, which otherwise never diff --git a/packages/backend/src/auth/email-verified.guard.ts b/packages/backend/src/auth/email-verified.guard.ts index 2b4a8d75d..316e9d605 100644 --- a/packages/backend/src/auth/email-verified.guard.ts +++ b/packages/backend/src/auth/email-verified.guard.ts @@ -34,6 +34,8 @@ export class EmailVerifiedGuard implements CanActivate { '/api/auth/forgot-password', '/api/auth/reset-password', '/api/auth/accept-invite', + // Unverified users must reach support (e.g. the code never arrived). + '/api/support/widget', '/health', ]; diff --git a/packages/backend/src/ee/README.md b/packages/backend/src/ee/README.md index 04c60417d..27d5f0dbf 100644 --- a/packages/backend/src/ee/README.md +++ b/packages/backend/src/ee/README.md @@ -6,6 +6,11 @@ the repository. - `cloud/` — operator-only functionality of AnythingMCP Cloud (e.g. onboarding lifecycle emails). Loaded only when `DEPLOYMENT_MODE=cloud`. +- `support/` — the live support chat of the instances we operate (Cloud + and dedicated enterprise instances), backed by a Libredesk desk: a + signed widget identity with a workspace snapshot, and an AI triage of + new conversations. Loaded only when `SUPPORT_CHAT_*` or `LIBREDESK_*` + are set; self-hosted installations never load it. - `licensing/` — which edition a self-hosted instance runs (Community or a licensed plan) and what each allows. Loaded everywhere; single sign-on, SCIM and role sync (Enterprise) are active only with an diff --git a/packages/backend/src/ee/support/libredesk.client.ts b/packages/backend/src/ee/support/libredesk.client.ts new file mode 100644 index 000000000..ab36a62cd --- /dev/null +++ b/packages/backend/src/ee/support/libredesk.client.ts @@ -0,0 +1,81 @@ +import { SupportTriageConfig } from './support-config'; + +const TIMEOUT_MS = 15_000; + +export interface LibredeskMessage { + type: string; + private: boolean; + sender_type: string; + text_content?: string; + content?: string; + created_at?: string; +} + +/** Minimal REST client for the endpoints the triage uses (API-key auth). */ +export class LibredeskClient { + constructor( + private readonly cfg: SupportTriageConfig, + private readonly fetchImpl: typeof fetch = fetch, + ) {} + + private async call(method: string, path: string, body?: unknown): Promise { + const res = await this.fetchImpl(`${this.cfg.baseUrl}${path}`, { + method, + headers: { + Authorization: `token ${this.cfg.apiKey}:${this.cfg.apiSecret}`, + 'Content-Type': 'application/json', + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + const text = await res.text(); + if (!res.ok) { + throw new Error(`Libredesk ${method} ${path} -> ${res.status}: ${text.slice(0, 200)}`); + } + const parsed = text ? JSON.parse(text) : {}; + return parsed?.data as T; + } + + async incomingMessages(uuid: string): Promise { + const page = await this.call<{ results?: LibredeskMessage[] }>( + 'GET', + `/api/v1/conversations/${encodeURIComponent(uuid)}/messages?page=1&page_size=30&type=incoming`, + ); + return page?.results ?? []; + } + + addTags(uuid: string, tags: string[]) { + return this.call('POST', `/api/v1/conversations/${encodeURIComponent(uuid)}/tags`, { + tags, + action: 'add_tags', + }); + } + + setPriority(uuid: string, priority: string) { + return this.call('PUT', `/api/v1/conversations/${encodeURIComponent(uuid)}/priority`, { priority }); + } + + assignTeam(uuid: string, teamId: number) { + return this.call('PUT', `/api/v1/conversations/${encodeURIComponent(uuid)}/assignee/team`, { + assignee_id: teamId, + }); + } + + assignUser(uuid: string, userId: number) { + return this.call('PUT', `/api/v1/conversations/${encodeURIComponent(uuid)}/assignee/user`, { + assignee_id: userId, + }); + } + + addPrivateNote(uuid: string, html: string) { + return this.call('POST', `/api/v1/conversations/${encodeURIComponent(uuid)}/messages`, { + message: html, + private: true, + sender_type: 'agent', + }); + } + + async teams(): Promise> { + return (await this.call>('GET', '/api/v1/teams/compact')) ?? []; + } +} diff --git a/packages/backend/src/ee/support/support-config.ts b/packages/backend/src/ee/support/support-config.ts new file mode 100644 index 000000000..d332b826e --- /dev/null +++ b/packages/backend/src/ee/support/support-config.ts @@ -0,0 +1,73 @@ +/** + * Environment for the support desk (a Libredesk instance operated by us). + * + * Two independent parts, each enabled by its own variables: + * - the chat widget (SUPPORT_CHAT_*): set on every instance we host, Cloud + * and the dedicated instances of enterprise customers; + * - the triage webhook (LIBREDESK_*): set on one instance only (Cloud), which + * receives every new conversation and tags, prioritises and annotates it. + * + * Self-hosted installations set none of these, so the module is not loaded. + */ + +export interface SupportChatConfig { + /** Libredesk root URL, e.g. https://support.anythingmcp.com */ + baseUrl: string; + /** UUID of the live chat inbox. */ + inboxId: string; + /** The inbox's identity-verification secret (HS256 key for the user JWT). */ + secret: string; +} + +export interface SupportTriageConfig { + baseUrl: string; + apiKey: string; + apiSecret: string; + webhookSecret: string; + /** + * Libredesk user id of the AI assistant. When set, chats in categories it + * can handle are assigned to it so it answers from the knowledge base; + * the rest go straight to a human team. + */ + aiAssistantUserId: number | null; +} + +const trimUrl = (v: string | undefined) => (v || '').trim().replace(/\/+$/, ''); + +export function supportChatConfig(env: NodeJS.ProcessEnv = process.env): SupportChatConfig | null { + const baseUrl = trimUrl(env.SUPPORT_CHAT_URL); + const inboxId = (env.SUPPORT_CHAT_INBOX_ID || '').trim(); + const secret = (env.SUPPORT_CHAT_SECRET || '').trim(); + if (!baseUrl || !inboxId || !secret) return null; + return { baseUrl, inboxId, secret }; +} + +export function supportTriageConfig(env: NodeJS.ProcessEnv = process.env): SupportTriageConfig | null { + const baseUrl = trimUrl(env.LIBREDESK_API_URL || env.SUPPORT_CHAT_URL); + const apiKey = (env.LIBREDESK_API_KEY || '').trim(); + const apiSecret = (env.LIBREDESK_API_SECRET || '').trim(); + const webhookSecret = (env.LIBREDESK_WEBHOOK_SECRET || '').trim(); + if (!baseUrl || !apiKey || !apiSecret || !webhookSecret) return null; + const assistant = Number.parseInt(env.LIBREDESK_AI_ASSISTANT_USER_ID || '', 10); + return { + baseUrl, + apiKey, + apiSecret, + webhookSecret, + aiAssistantUserId: Number.isInteger(assistant) && assistant > 0 ? assistant : null, + }; +} + +export function supportModuleEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + return supportChatConfig(env) !== null || supportTriageConfig(env) !== null; +} + +/** Host name of this instance, used to tell instances apart in the desk. */ +export function instanceHost(env: NodeJS.ProcessEnv = process.env): string { + const raw = env.FRONTEND_URL || env.SERVER_URL || ''; + try { + return new URL(raw).host || 'unknown'; + } catch { + return 'unknown'; + } +} diff --git a/packages/backend/src/ee/support/support-identity.service.spec.ts b/packages/backend/src/ee/support/support-identity.service.spec.ts new file mode 100644 index 000000000..6b86291b9 --- /dev/null +++ b/packages/backend/src/ee/support/support-identity.service.spec.ts @@ -0,0 +1,159 @@ +import { NotFoundException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { SupportIdentityService, splitName } from './support-identity.service'; +import { instanceHost, supportChatConfig, supportModuleEnabled, supportTriageConfig } from './support-config'; + +const SAVED = { ...process.env }; + +function fakePrisma() { + return { + user: { + findUnique: jest.fn().mockImplementation(({ select }) => + Promise.resolve( + select.email + ? { id: 'u1', email: 'ada@example.com', name: 'Ada Lovelace' } + : { role: 'ADMIN', createdAt: new Date('2026-10-01T10:00:00Z'), emailVerified: true }, + ), + ), + }, + organization: { findUnique: jest.fn().mockResolvedValue({ name: 'Acme' }) }, + connector: { + findMany: jest.fn().mockResolvedValue([ + { id: 'c1', name: 'Etsy', isActive: true, authStatus: 'needs_reauth', authStatusMessage: 'refresh token revoked' }, + { id: 'c2', name: 'Shopify', isActive: true, authStatus: null, authStatusMessage: null }, + { id: 'c3', name: 'Old', isActive: false, authStatus: null, authStatusMessage: null }, + ]), + }, + toolInvocation: { + groupBy: jest.fn().mockResolvedValue([{ connectorId: 'c2', _count: { _all: 7 } }]), + findFirst: jest.fn().mockResolvedValue({ + error: '401 Unauthorized: token=FAKE-not-a-real-credential-0000', + tool: { name: 'shopify_list_orders' }, + }), + }, + }; +} + +function build(isCloud = true) { + const prisma = fakePrisma(); + const deployment = { isCloud: () => isCloud }; + const license = { + getCurrentLicense: jest.fn().mockResolvedValue({ + plan: 'trial', + status: 'active', + expiresAt: new Date('2026-10-26T00:00:00Z'), + }), + }; + return { svc: new SupportIdentityService(prisma as any, deployment as any, license as any), prisma, license }; +} + +describe('support config', () => { + afterEach(() => { + process.env = { ...SAVED }; + }); + + it('is off unless every variable of a part is set', () => { + const env = { SUPPORT_CHAT_URL: 'https://desk.test/', SUPPORT_CHAT_INBOX_ID: 'i' } as any; + expect(supportChatConfig(env)).toBeNull(); + expect(supportModuleEnabled(env)).toBe(false); + expect(supportChatConfig({ ...env, SUPPORT_CHAT_SECRET: 's' })).toEqual({ + baseUrl: 'https://desk.test', + inboxId: 'i', + secret: 's', + }); + expect( + supportTriageConfig({ + SUPPORT_CHAT_URL: 'https://desk.test', + LIBREDESK_API_KEY: 'k', + LIBREDESK_API_SECRET: 's', + LIBREDESK_WEBHOOK_SECRET: 'w', + } as any)?.baseUrl, + ).toBe('https://desk.test'); + const triage = { + SUPPORT_CHAT_URL: 'https://desk.test', + LIBREDESK_API_KEY: 'k', + LIBREDESK_API_SECRET: 's', + LIBREDESK_WEBHOOK_SECRET: 'w', + } as any; + expect(supportTriageConfig(triage)?.aiAssistantUserId).toBeNull(); + expect(supportTriageConfig({ ...triage, LIBREDESK_AI_ASSISTANT_USER_ID: '5' })?.aiAssistantUserId).toBe(5); + expect(supportTriageConfig({ ...triage, LIBREDESK_AI_ASSISTANT_USER_ID: 'x' })?.aiAssistantUserId).toBeNull(); + }); + + it('instance host comes from FRONTEND_URL', () => { + expect(instanceHost({ FRONTEND_URL: 'https://cloud.anythingmcp.com' } as any)).toBe('cloud.anythingmcp.com'); + expect(instanceHost({} as any)).toBe('unknown'); + }); +}); + +describe('SupportIdentityService', () => { + beforeEach(() => { + process.env.SUPPORT_CHAT_URL = 'https://desk.test'; + process.env.SUPPORT_CHAT_INBOX_ID = 'inbox-uuid'; + process.env.SUPPORT_CHAT_SECRET = 'inbox-secret'; + process.env.FRONTEND_URL = 'https://cloud.anythingmcp.com'; + }); + afterEach(() => { + process.env = { ...SAVED }; + }); + + it('signs an HS256 identity the desk can verify, scoped per instance', async () => { + const { svc } = build(); + const session = await svc.widgetSession('u1', 'org1'); + expect(session).toMatchObject({ baseUrl: 'https://desk.test', inboxId: 'inbox-uuid' }); + + const claims: any = new JwtService().verify(session.userJwt, { secret: 'inbox-secret', algorithms: ['HS256'] }); + expect(claims.external_user_id).toBe('cloud.anythingmcp.com:u1'); + expect(claims.email).toBe('ada@example.com'); + expect(claims.first_name).toBe('Ada'); + expect(claims.last_name).toBe('Lovelace'); + expect(claims.exp - claims.iat).toBe(3600); + expect(claims.contact_custom_attributes).toMatchObject({ + deployment: 'cloud', + organization_name: 'Acme', + plan: 'trial', + trial_ends_at: '2026-10-26', + connectors: '3 (1 inactive)', + }); + }); + + it('summarises connector issues without leaking credentials', async () => { + const { svc } = build(); + const attrs = await svc.contactAttributes('u1', 'org1'); + expect(attrs.connector_issues).toContain('Etsy: auth needs_reauth'); + expect(attrs.connector_issues).toContain('Shopify: 7 failed calls/24h, last shopify_list_orders: 401'); + expect(attrs.connector_issues).not.toContain('FAKE-not-a-real-credential-0000'); + }); + + it('filters every query by the caller organization', async () => { + const { svc, prisma, license } = build(); + await svc.contactAttributes('u1', 'org1'); + expect(prisma.connector.findMany.mock.calls[0][0].where).toEqual({ organizationId: 'org1' }); + expect(prisma.toolInvocation.groupBy.mock.calls[0][0].where.organizationId).toBe('org1'); + expect(prisma.toolInvocation.findFirst.mock.calls[0][0].where.organizationId).toBe('org1'); + expect(license.getCurrentLicense).toHaveBeenCalledWith('org1'); + }); + + it('caches the snapshot for a few minutes', async () => { + const { svc, prisma } = build(); + await svc.contactAttributes('u1', 'org1'); + await svc.contactAttributes('u1', 'org1'); + expect(prisma.connector.findMany).toHaveBeenCalledTimes(1); + }); + + it('marks dedicated instances', async () => { + const { svc } = build(false); + expect((await svc.contactAttributes('u1', 'org1')).deployment).toBe('dedicated'); + }); + + it('is 404 when the chat is not configured', async () => { + delete process.env.SUPPORT_CHAT_SECRET; + const { svc } = build(); + await expect(svc.widgetSession('u1', 'org1')).rejects.toBeInstanceOf(NotFoundException); + }); + + it('splitName falls back to the email local part', () => { + expect(splitName(null, 'bob@x.test')).toEqual({ first: 'bob', last: '' }); + expect(splitName(' Grace Brewster Hopper ', 'g@x.test')).toEqual({ first: 'Grace', last: 'Brewster Hopper' }); + }); +}); diff --git a/packages/backend/src/ee/support/support-identity.service.ts b/packages/backend/src/ee/support/support-identity.service.ts new file mode 100644 index 000000000..c6729bca0 --- /dev/null +++ b/packages/backend/src/ee/support/support-identity.service.ts @@ -0,0 +1,172 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { PrismaService } from '../../common/prisma.service'; +import { DeploymentService } from '../../common/deployment.service'; +import { LicenseService } from '../../license/license.service'; +import { instanceHost, supportChatConfig } from './support-config'; +import { compactError } from './support-redact'; + +/** Contact attributes shown to agents and read by the triage. All strings. */ +export type SupportContactAttributes = Record; + +export interface SupportWidgetSession { + baseUrl: string; + inboxId: string; + userJwt: string; +} + +const CONTEXT_TTL_MS = 5 * 60_000; +const CONTEXT_CACHE_MAX = 5_000; +const JWT_TTL_SECONDS = 60 * 60; +const ERROR_WINDOW_MS = 24 * 60 * 60_000; +const MAX_ISSUES = 5; +const ISSUES_MAX_CHARS = 900; + +/** + * Builds the signed identity the Libredesk widget exchanges for a verified + * contact, carrying a snapshot of the workspace (plan, trial end, connectors + * that are failing) so agents and the triage see it without asking. + * + * Organization-scoped: every query filters by the caller's organizationId. + */ +@Injectable() +export class SupportIdentityService { + private readonly jwt = new JwtService(); + private readonly cache = new Map(); + + constructor( + private readonly prisma: PrismaService, + private readonly deployment: DeploymentService, + private readonly license: LicenseService, + ) {} + + async widgetSession(userId: string, organizationId: string | null): Promise { + const cfg = supportChatConfig(); + if (!cfg) throw new NotFoundException(); + + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, email: true, name: true }, + }); + if (!user) throw new NotFoundException(); + + const attrs = await this.contactAttributes(userId, organizationId); + const { first, last } = splitName(user.name, user.email); + const userJwt = this.jwt.sign( + { + // Unique per instance: the same person may exist on Cloud and on a + // dedicated instance, and those are different accounts. + external_user_id: `${instanceHost()}:${user.id}`.slice(0, 128), + email: user.email.slice(0, 254), + first_name: first, + ...(last ? { last_name: last } : {}), + contact_custom_attributes: attrs, + }, + { secret: cfg.secret, algorithm: 'HS256', expiresIn: JWT_TTL_SECONDS }, + ); + return { baseUrl: cfg.baseUrl, inboxId: cfg.inboxId, userJwt }; + } + + /** Cached for a few minutes: the widget asks on every full page load. */ + async contactAttributes(userId: string, organizationId: string | null): Promise { + const key = `${organizationId ?? ''}:${userId}`; + const hit = this.cache.get(key); + if (hit && Date.now() - hit.at < CONTEXT_TTL_MS) return hit.attrs; + + const attrs = await this.buildAttributes(userId, organizationId); + if (this.cache.size >= CONTEXT_CACHE_MAX) { + const oldest = this.cache.keys().next().value; + if (oldest !== undefined) this.cache.delete(oldest); + } + this.cache.set(key, { at: Date.now(), attrs }); + return attrs; + } + + private async buildAttributes(userId: string, organizationId: string | null): Promise { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { role: true, createdAt: true, emailVerified: true }, + }); + const attrs: SupportContactAttributes = { + instance: instanceHost(), + deployment: this.deployment.isCloud() ? 'cloud' : 'dedicated', + role: user?.role ?? '', + signed_up_at: user?.createdAt ? user.createdAt.toISOString().slice(0, 10) : '', + }; + if (user && !user.emailVerified) attrs.email_verified = 'no'; + if (!organizationId) return attrs; + + const [org, license, connectors] = await Promise.all([ + this.prisma.organization.findUnique({ where: { id: organizationId }, select: { name: true } }), + this.license.getCurrentLicense(organizationId).catch(() => null), + this.prisma.connector.findMany({ + where: { organizationId }, + select: { id: true, name: true, isActive: true, authStatus: true, authStatusMessage: true }, + orderBy: { createdAt: 'asc' }, + }), + ]); + + attrs.organization_id = organizationId; + attrs.organization_name = org?.name ?? ''; + attrs.plan = license?.plan ?? 'none'; + attrs.license_status = license?.status ?? ''; + if (license?.plan === 'trial' && license.expiresAt) { + attrs.trial_ends_at = new Date(license.expiresAt).toISOString().slice(0, 10); + } + + const inactive = connectors.filter((c) => !c.isActive).length; + attrs.connectors = inactive ? `${connectors.length} (${inactive} inactive)` : String(connectors.length); + attrs.connector_issues = await this.connectorIssues(organizationId, connectors); + return attrs; + } + + /** + * One line per connector that needs attention: an auth problem, or calls + * that failed in the last 24 hours with the latest error (redacted). + */ + private async connectorIssues( + organizationId: string, + connectors: Array<{ id: string; name: string; authStatus: string | null; authStatusMessage: string | null }>, + ): Promise { + if (connectors.length === 0) return ''; + const since = new Date(Date.now() - ERROR_WINDOW_MS); + const failing = await this.prisma.toolInvocation.groupBy({ + by: ['connectorId'], + where: { organizationId, status: { in: ['ERROR', 'TIMEOUT'] }, createdAt: { gte: since } }, + _count: { _all: true }, + }); + const errorCount = new Map(); + for (const row of failing) if (row.connectorId) errorCount.set(row.connectorId, row._count._all); + + const lines: string[] = []; + for (const c of connectors) { + if (lines.length >= MAX_ISSUES) break; + const parts: string[] = []; + if (c.authStatus) { + parts.push(`auth ${c.authStatus}${c.authStatusMessage ? ` (${compactError(c.authStatusMessage, 80)})` : ''}`); + } + const errors = errorCount.get(c.id); + if (errors) { + const last = await this.prisma.toolInvocation.findFirst({ + where: { organizationId, connectorId: c.id, status: { in: ['ERROR', 'TIMEOUT'] }, createdAt: { gte: since } }, + orderBy: { createdAt: 'desc' }, + select: { error: true, tool: { select: { name: true } } }, + }); + const tool = last?.tool?.name ? `${last.tool.name}: ` : ''; + parts.push(`${errors} failed calls/24h${last?.error ? `, last ${tool}${compactError(last.error)}` : ''}`); + } + if (parts.length) lines.push(`${c.name}: ${parts.join('; ')}`); + } + const text = lines.join(' | '); + return text.length > ISSUES_MAX_CHARS ? `${text.slice(0, ISSUES_MAX_CHARS - 1)}…` : text; + } +} + +export function splitName(name: string | null | undefined, email: string): { first: string; last: string } { + const clean = (name || '').trim().replace(/\s+/g, ' '); + if (clean) { + const [first, ...rest] = clean.split(' '); + return { first: first.slice(0, 128), last: rest.join(' ').slice(0, 128) }; + } + return { first: (email.split('@')[0] || 'User').slice(0, 128), last: '' }; +} diff --git a/packages/backend/src/ee/support/support-redact.ts b/packages/backend/src/ee/support/support-redact.ts new file mode 100644 index 000000000..1520814b8 --- /dev/null +++ b/packages/backend/src/ee/support/support-redact.ts @@ -0,0 +1,29 @@ +/** + * Scrubs credentials out of text that leaves the instance for the support + * desk or its AI triage: upstream error messages sometimes echo the request + * (a bearer token, an api_key query parameter, a signed URL). + */ +const SECRET_PATTERNS: Array<[RegExp, string]> = [ + [/\b(Bearer|Basic|token)\s+[A-Za-z0-9._~+/=:-]{8,}/gi, '$1 [redacted]'], + [ + /\b(api[_-]?key|apikey|access[_-]?token|refresh[_-]?token|client[_-]?secret|secret|password|passwd|pwd|token|signature|sig|key)(["']?\s*[:=]\s*["']?)[^\s"'&,;}]+/gi, + '$1$2[redacted]', + ], + // JWTs + [/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, '[jwt]'], + // Long opaque strings (keys, hashes) that survived the rules above + [/\b[A-Za-z0-9_-]{32,}\b/g, '[redacted]'], +]; + +export function redactSecrets(text: string | null | undefined): string { + if (!text) return ''; + let out = text; + for (const [re, repl] of SECRET_PATTERNS) out = out.replace(re, repl); + return out; +} + +/** One line, at most `max` characters, credentials removed. */ +export function compactError(text: string | null | undefined, max = 160): string { + const flat = redactSecrets(text).replace(/\s+/g, ' ').trim(); + return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat; +} diff --git a/packages/backend/src/ee/support/support-triage.service.spec.ts b/packages/backend/src/ee/support/support-triage.service.spec.ts new file mode 100644 index 000000000..e90ef50b7 --- /dev/null +++ b/packages/backend/src/ee/support/support-triage.service.spec.ts @@ -0,0 +1,197 @@ +import { + SupportTriageService, + fallbackTriage, + normaliseAttrs, + parseTriage, + segmentTags, + triageNote, +} from './support-triage.service'; +import { compactError, redactSecrets } from './support-redact'; +import * as llm from '../../knowledge-graph/llm-client'; + +function fakeDesk(messages: any[] = []) { + return { + incomingMessages: jest.fn().mockResolvedValue(messages), + addTags: jest.fn().mockResolvedValue(true), + setPriority: jest.fn().mockResolvedValue(true), + assignTeam: jest.fn().mockResolvedValue(true), + assignUser: jest.fn().mockResolvedValue(true), + addPrivateNote: jest.fn().mockResolvedValue(true), + teams: jest.fn().mockResolvedValue([ + { id: 1, name: 'Support' }, + { id: 2, name: 'Enterprise' }, + ]), + }; +} + +class TestTriage extends SupportTriageService { + constructor( + private readonly desk: any, + private readonly llmCfg: any = { provider: 'anthropic', model: 'm', apiKey: 'k' }, + private readonly assistantId: number | null = null, + ) { + super(); + } + protected client() { + return this.desk; + } + protected aiAssistantUserId() { + return this.assistantId; + } + protected llm() { + return this.llmCfg; + } +} + +describe('support triage helpers', () => { + it('segment tags follow deployment and plan', () => { + expect(segmentTags({ deployment: 'cloud', plan: 'trial' })).toEqual(['cloud', 'trial']); + expect(segmentTags({ deployment: 'cloud', plan: 'starter' })).toEqual(['cloud', 'paid']); + expect(segmentTags({ deployment: 'cloud', plan: 'none' })).toEqual(['cloud']); + expect(segmentTags({ deployment: 'dedicated', plan: 'enterprise' })).toEqual([ + 'self-hosted', + 'enterprise', + 'paid', + ]); + }); + + it('parseTriage clamps unknown values and raises billing and enterprise to High', () => { + expect(parseTriage({ category: 'nonsense', priority: 'Urgent' }, {})).toMatchObject({ + category: 'other', + priority: 'Medium', + }); + expect(parseTriage({ category: 'billing', priority: 'Low' }, {})?.priority).toBe('High'); + expect(parseTriage({ category: 'bug', priority: 'Low' }, { deployment: 'dedicated' })?.priority).toBe('High'); + expect(parseTriage(null, {})).toBeNull(); + }); + + it('reads only the known contact attributes', () => { + const raw = JSON.parse('{"plan":"trial","__proto__":{"x":1},"constructor":"y","other":"z","connectors":3}'); + const out = normaliseAttrs(raw); + expect(out).toEqual({ plan: 'trial', connectors: '3' }); + expect(({} as any).x).toBeUndefined(); + }); + + it('fallback keeps enterprise conversations at High', () => { + expect(fallbackTriage({ deployment: 'dedicated' }).priority).toBe('High'); + expect(fallbackTriage({ deployment: 'cloud' }).priority).toBe('Medium'); + }); + + it('the note escapes model output', () => { + const html = triageNote( + { category: 'bug', priority: 'High', language: 'it', summary: '', suggestedReply: 'a\nb' }, + { plan: 'trial', connector_issues: 'Etsy: auth needs_reauth' }, + ); + expect(html).not.toContain('