Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docker-compose.cloud.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions packages/backend/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Module, MiddlewareConsumer, NestModule, Logger, ClassSerializerInterceptor } from '@nestjs/common';

Check warning on line 1 in packages/backend/src/app.module.ts

View workflow job for this annotation

GitHub Actions / Backend (lint, typecheck, test, build)

'ClassSerializerInterceptor' is defined but never used. Allowed unused vars must match /^_/u
import { ConfigModule, ConfigService } from '@nestjs/config';
import { join } from 'path';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
Expand Down Expand Up @@ -44,13 +44,18 @@
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';

// 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';
Expand Down Expand Up @@ -175,6 +180,7 @@

// Cloud-specific modules (conditionally loaded)
...cloudImports,
...supportImports,
],
providers: [
// Reports exceptions Nest turns into a 500 response, which otherwise never
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/auth/email-verified.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];

Expand Down
5 changes: 5 additions & 0 deletions packages/backend/src/ee/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 81 additions & 0 deletions packages/backend/src/ee/support/libredesk.client.ts
Original file line number Diff line number Diff line change
@@ -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<T = unknown>(method: string, path: string, body?: unknown): Promise<T> {
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<LibredeskMessage[]> {
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<Array<{ id: number; name: string }>> {
return (await this.call<Array<{ id: number; name: string }>>('GET', '/api/v1/teams/compact')) ?? [];
}
}
73 changes: 73 additions & 0 deletions packages/backend/src/ee/support/support-config.ts
Original file line number Diff line number Diff line change
@@ -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';
}
}
159 changes: 159 additions & 0 deletions packages/backend/src/ee/support/support-identity.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
Loading
Loading