@app-connect/core 1.7.20 → 1.7.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +8 -1
  2. package/connector/developerPortal.js +4 -4
  3. package/docs/README.md +50 -0
  4. package/docs/architecture.md +93 -0
  5. package/docs/connectors.md +117 -0
  6. package/docs/handlers.md +125 -0
  7. package/docs/libraries.md +101 -0
  8. package/docs/models.md +144 -0
  9. package/docs/routes.md +115 -0
  10. package/docs/tests.md +73 -0
  11. package/handlers/admin.js +22 -2
  12. package/handlers/auth.js +51 -10
  13. package/handlers/contact.js +7 -2
  14. package/handlers/log.js +4 -4
  15. package/handlers/managedAuth.js +446 -0
  16. package/index.js +263 -38
  17. package/lib/jwt.js +1 -1
  18. package/mcp/tools/createCallLog.js +5 -1
  19. package/mcp/tools/createContact.js +5 -1
  20. package/mcp/tools/createMessageLog.js +5 -1
  21. package/mcp/tools/findContactByName.js +5 -1
  22. package/mcp/tools/findContactByPhone.js +6 -2
  23. package/mcp/tools/getCallLog.js +5 -1
  24. package/mcp/tools/rcGetCallLogs.js +6 -2
  25. package/mcp/tools/updateCallLog.js +5 -1
  26. package/mcp/ui/App/lib/developerPortal.ts +1 -1
  27. package/package.json +1 -1
  28. package/releaseNotes.json +20 -0
  29. package/test/handlers/admin.test.js +34 -0
  30. package/test/handlers/auth.test.js +402 -6
  31. package/test/handlers/contact.test.js +162 -0
  32. package/test/handlers/managedAuth.test.js +458 -0
  33. package/test/index.test.js +105 -0
  34. package/test/lib/jwt.test.js +15 -0
  35. package/test/mcp/tools/createCallLog.test.js +11 -0
  36. package/test/mcp/tools/createContact.test.js +58 -0
  37. package/test/mcp/tools/createMessageLog.test.js +15 -0
  38. package/test/mcp/tools/findContactByName.test.js +12 -0
  39. package/test/mcp/tools/findContactByPhone.test.js +12 -0
  40. package/test/mcp/tools/getCallLog.test.js +12 -0
  41. package/test/mcp/tools/rcGetCallLogs.test.js +56 -0
  42. package/test/mcp/tools/updateCallLog.test.js +14 -0
  43. package/test/routes/managedAuthRoutes.test.js +132 -0
  44. package/test/setup.js +2 -0
@@ -0,0 +1,105 @@
1
+ const express = require('express');
2
+ const request = require('supertest');
3
+
4
+ jest.mock('../lib/jwt', () => ({
5
+ decodeJwt: jest.fn(),
6
+ generateJwt: jest.fn(),
7
+ }));
8
+ jest.mock('../handlers/auth', () => ({
9
+ authValidation: jest.fn(),
10
+ }));
11
+ jest.mock('../lib/analytics', () => ({
12
+ init: jest.fn(),
13
+ track: jest.fn(),
14
+ }));
15
+
16
+ const jwt = require('../lib/jwt');
17
+ const authCore = require('../handlers/auth');
18
+ const { createCoreRouter, createCoreMiddleware } = require('../index');
19
+
20
+ function buildApp() {
21
+ const app = express();
22
+ createCoreMiddleware().forEach((m) => app.use(m));
23
+ app.use('/', createCoreRouter());
24
+ return app;
25
+ }
26
+
27
+ describe('Core Router JWT normalization', () => {
28
+ beforeEach(() => {
29
+ jest.clearAllMocks();
30
+ });
31
+
32
+ test('should accept query jwtToken without refreshing it', async () => {
33
+ jwt.decodeJwt.mockReturnValue({ id: 'user-1', platform: 'testCRM' });
34
+ authCore.authValidation.mockResolvedValue({
35
+ successful: true,
36
+ returnMessage: { message: 'ok' },
37
+ failReason: null,
38
+ status: 200,
39
+ });
40
+ const app = buildApp();
41
+
42
+ const response = await request(app).get('/authValidation?jwtToken=query-token');
43
+
44
+ expect(response.status).toBe(200);
45
+ expect(response.headers['x-refreshed-jwt-token']).toBeUndefined();
46
+ expect(authCore.authValidation).toHaveBeenCalledWith({
47
+ platform: 'testCRM',
48
+ userId: 'user-1',
49
+ });
50
+ expect(jwt.generateJwt).not.toHaveBeenCalled();
51
+ });
52
+
53
+ test('should refresh near-expiry bearer token and expose header', async () => {
54
+ const nowMs = 1700000000000;
55
+ const nowSeconds = Math.floor(nowMs / 1000);
56
+ const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(nowMs);
57
+ jwt.decodeJwt.mockImplementation((token) => {
58
+ if (token === 'old-token') {
59
+ return { id: 'user-1', platform: 'testCRM', exp: nowSeconds + 60 };
60
+ }
61
+ if (token === 'new-token') {
62
+ return { id: 'user-1', platform: 'testCRM', exp: nowSeconds + (14 * 24 * 60 * 60) };
63
+ }
64
+ return null;
65
+ });
66
+ jwt.generateJwt.mockReturnValue('new-token');
67
+ const app = buildApp();
68
+
69
+ const response = await request(app)
70
+ .get('/isAlive')
71
+ .set('Authorization', 'Bearer old-token')
72
+ .set('Origin', 'https://example.com');
73
+
74
+ expect(response.status).toBe(200);
75
+ expect(response.headers['x-refreshed-jwt-token']).toBe('new-token');
76
+ expect(response.headers['access-control-expose-headers']).toContain('x-refreshed-jwt-token');
77
+ expect(jwt.generateJwt).toHaveBeenCalledWith({ id: 'user-1', platform: 'testCRM' });
78
+ nowSpy.mockRestore();
79
+ });
80
+
81
+ test('should treat invalid bearer token as unauthenticated for authValidation route', async () => {
82
+ jwt.decodeJwt.mockReturnValue(null);
83
+ const app = buildApp();
84
+
85
+ const response = await request(app)
86
+ .get('/authValidation?jwtToken=query-token')
87
+ .set('Authorization', 'Bearer invalid-token');
88
+
89
+ expect(response.status).toBe(400);
90
+ expect(response.text).toContain('authorize CRM platform');
91
+ expect(authCore.authValidation).not.toHaveBeenCalled();
92
+ });
93
+
94
+ test('should bypass normalization for /mcp routes', async () => {
95
+ const app = buildApp();
96
+
97
+ const response = await request(app)
98
+ .get('/mcp')
99
+ .set('Authorization', 'Bearer maybe-token');
100
+
101
+ expect(response.status).toBe(404);
102
+ expect(jwt.decodeJwt).not.toHaveBeenCalled();
103
+ });
104
+ });
105
+
@@ -35,6 +35,21 @@ describe('JWT Utility', () => {
35
35
  // Assert
36
36
  expect(token1).not.toBe(token2);
37
37
  });
38
+
39
+ test('should generate token with about 2 weeks lifetime', () => {
40
+ // Arrange
41
+ const payload = { id: 'user-ttl', platform: 'testCRM' };
42
+
43
+ // Act
44
+ const token = jwt.generateJwt(payload);
45
+ const decoded = jwt.decodeJwt(token);
46
+ const lifetimeSeconds = decoded.exp - decoded.iat;
47
+
48
+ // Assert
49
+ // Keep a tiny tolerance to avoid timing flakiness.
50
+ expect(lifetimeSeconds).toBeGreaterThanOrEqual((14 * 24 * 60 * 60) - 2);
51
+ expect(lifetimeSeconds).toBeLessThanOrEqual((14 * 24 * 60 * 60) + 2);
52
+ });
38
53
  });
39
54
 
40
55
  describe('decodeJwt', () => {
@@ -292,6 +292,17 @@ describe('MCP Tool: createCallLog', () => {
292
292
  expect(result.error).toContain('Invalid JWT token');
293
293
  });
294
294
 
295
+ test('should return error when decodeJwt returns null', async () => {
296
+ jwt.decodeJwt.mockReturnValue(null);
297
+ const result = await createCallLog.execute({
298
+ jwtToken: 'invalid-token',
299
+ incomingData: { logInfo: { sessionId: 'session-123' } }
300
+ });
301
+
302
+ expect(result.success).toBe(false);
303
+ expect(result.error).toContain('Invalid JWT token');
304
+ });
305
+
295
306
  test('should return error when platform connector not found', async () => {
296
307
  // Arrange
297
308
  const mockIncomingData = {
@@ -0,0 +1,58 @@
1
+ const createContact = require('../../../mcp/tools/createContact');
2
+ const jwt = require('../../../lib/jwt');
3
+ const connectorRegistry = require('../../../connector/registry');
4
+ const contactCore = require('../../../handlers/contact');
5
+
6
+ jest.mock('../../../lib/jwt');
7
+ jest.mock('../../../connector/registry');
8
+ jest.mock('../../../handlers/contact');
9
+
10
+ describe('MCP Tool: createContact', () => {
11
+ beforeEach(() => {
12
+ jest.clearAllMocks();
13
+ });
14
+
15
+ test('should have correct tool definition', () => {
16
+ expect(createContact.definition).toBeDefined();
17
+ expect(createContact.definition.name).toBe('createContact');
18
+ expect(createContact.definition.inputSchema.required).toContain('phoneNumber');
19
+ });
20
+
21
+ test('should create contact successfully', async () => {
22
+ jwt.decodeJwt.mockReturnValue({ id: 'user-123', platform: 'testCRM' });
23
+ connectorRegistry.getConnector.mockReturnValue({ createContact: jest.fn() });
24
+ contactCore.createContact.mockResolvedValue({
25
+ successful: true,
26
+ returnMessage: { message: 'Created' },
27
+ contact: { id: 'contact-1' }
28
+ });
29
+
30
+ const result = await createContact.execute({
31
+ jwtToken: 'mock-jwt-token',
32
+ phoneNumber: '+14155551234',
33
+ newContactName: 'John Doe'
34
+ });
35
+
36
+ expect(result).toEqual({
37
+ success: true,
38
+ data: {
39
+ contact: { id: 'contact-1' },
40
+ message: 'Created'
41
+ }
42
+ });
43
+ });
44
+
45
+ test('should return error when decodeJwt returns null', async () => {
46
+ jwt.decodeJwt.mockReturnValue(null);
47
+
48
+ const result = await createContact.execute({
49
+ jwtToken: 'invalid-jwt',
50
+ phoneNumber: '+14155551234',
51
+ newContactName: 'John Doe'
52
+ });
53
+
54
+ expect(result.success).toBe(false);
55
+ expect(result.error).toContain('Invalid JWT token');
56
+ });
57
+ });
58
+
@@ -427,6 +427,21 @@ describe('MCP Tool: createMessageLog', () => {
427
427
  expect(result.error).toContain('Invalid JWT token');
428
428
  });
429
429
 
430
+ test('should return error when decodeJwt returns null', async () => {
431
+ jwt.decodeJwt.mockReturnValue(null);
432
+
433
+ const result = await createMessageLog.execute({
434
+ jwtToken: 'invalid-token',
435
+ incomingData: {
436
+ sessionId: 'session-123',
437
+ messageInfo: { from: { phoneNumber: '+1234567890' }, to: [{ phoneNumber: '+1098765432' }] }
438
+ }
439
+ });
440
+
441
+ expect(result.success).toBe(false);
442
+ expect(result.error).toContain('Invalid JWT token');
443
+ });
444
+
430
445
  test('should return error when platform connector not found', async () => {
431
446
  // Arrange
432
447
  const mockIncomingData = {
@@ -162,6 +162,18 @@ describe('MCP Tool: findContactByName', () => {
162
162
  expect(result.error).toContain('Invalid JWT token');
163
163
  });
164
164
 
165
+ test('should return error when decodeJwt returns null', async () => {
166
+ jwt.decodeJwt.mockReturnValue(null);
167
+
168
+ const result = await findContactByName.execute({
169
+ jwtToken: 'invalid-token',
170
+ name: 'John Doe'
171
+ });
172
+
173
+ expect(result.success).toBe(false);
174
+ expect(result.error).toContain('Invalid JWT token');
175
+ });
176
+
165
177
  test('should return error when platform connector not found', async () => {
166
178
  // Arrange
167
179
  jwt.decodeJwt.mockReturnValue({
@@ -211,6 +211,18 @@ describe('MCP Tool: findContactByPhone', () => {
211
211
  expect(result.error).toContain('Invalid JWT token');
212
212
  });
213
213
 
214
+ test('should return error when decodeJwt returns null', async () => {
215
+ jwt.decodeJwt.mockReturnValue(null);
216
+
217
+ const result = await findContactByPhone.execute({
218
+ jwtToken: 'invalid-token',
219
+ phoneNumber: '+1234567890'
220
+ });
221
+
222
+ expect(result.success).toBe(false);
223
+ expect(result.error).toContain('Invalid JWT token');
224
+ });
225
+
214
226
  test('should return error when platform connector not found', async () => {
215
227
  // Arrange
216
228
  jwt.decodeJwt.mockReturnValue({
@@ -213,6 +213,18 @@ describe('MCP Tool: getCallLog', () => {
213
213
  expect(result.error).toContain('Invalid JWT token');
214
214
  });
215
215
 
216
+ test('should return error when decodeJwt returns null', async () => {
217
+ jwt.decodeJwt.mockReturnValue(null);
218
+
219
+ const result = await getCallLog.execute({
220
+ jwtToken: 'invalid-token',
221
+ sessionIds: ['session-123']
222
+ });
223
+
224
+ expect(result.success).toBe(false);
225
+ expect(result.error).toContain('Invalid JWT token');
226
+ });
227
+
216
228
  test('should return error when platform connector not found', async () => {
217
229
  // Arrange
218
230
  jwt.decodeJwt.mockReturnValue({
@@ -0,0 +1,56 @@
1
+ const rcGetCallLogs = require('../../../mcp/tools/rcGetCallLogs');
2
+ const jwt = require('../../../lib/jwt');
3
+ const { RingCentral } = require('../../../lib/ringcentral');
4
+
5
+ jest.mock('../../../lib/jwt');
6
+ jest.mock('../../../lib/ringcentral', () => ({
7
+ RingCentral: jest.fn()
8
+ }));
9
+
10
+ describe('MCP Tool: rcGetCallLogs', () => {
11
+ beforeEach(() => {
12
+ jest.clearAllMocks();
13
+ process.env.RINGCENTRAL_SERVER = 'https://platform.ringcentral.com';
14
+ process.env.RINGCENTRAL_CLIENT_ID = 'client-id';
15
+ process.env.RINGCENTRAL_CLIENT_SECRET = 'client-secret';
16
+ process.env.APP_SERVER = 'https://app.example.com';
17
+ });
18
+
19
+ test('should have correct tool definition', () => {
20
+ expect(rcGetCallLogs.definition).toBeDefined();
21
+ expect(rcGetCallLogs.definition.name).toBe('rcGetCallLogs');
22
+ });
23
+
24
+ test('should return call logs successfully', async () => {
25
+ jwt.decodeJwt.mockReturnValue({ id: 'user-123' });
26
+ const getCallLogData = jest.fn().mockResolvedValue({ records: [{ id: '1' }] });
27
+ RingCentral.mockImplementation(() => ({ getCallLogData }));
28
+
29
+ const result = await rcGetCallLogs.execute({
30
+ jwtToken: 'mock-jwt',
31
+ rcAccessToken: 'rc-token',
32
+ timeFrom: '2026-04-01T00:00:00.000Z',
33
+ timeTo: '2026-04-02T00:00:00.000Z'
34
+ });
35
+
36
+ expect(result).toEqual({ records: [{ id: '1' }] });
37
+ expect(getCallLogData).toHaveBeenCalledWith({
38
+ token: { access_token: 'rc-token', token_type: 'Bearer' },
39
+ timeFrom: '2026-04-01T00:00:00.000Z',
40
+ timeTo: '2026-04-02T00:00:00.000Z'
41
+ });
42
+ });
43
+
44
+ test('should return error when decodeJwt returns null', async () => {
45
+ jwt.decodeJwt.mockReturnValue(null);
46
+
47
+ const result = await rcGetCallLogs.execute({
48
+ jwtToken: 'bad-jwt',
49
+ rcAccessToken: 'rc-token'
50
+ });
51
+
52
+ expect(result.success).toBe(false);
53
+ expect(result.error).toContain('Invalid JWT token');
54
+ });
55
+ });
56
+
@@ -191,6 +191,20 @@ describe('MCP Tool: updateCallLog', () => {
191
191
  expect(result.error).toContain('Invalid JWT token');
192
192
  });
193
193
 
194
+ test('should return error when decodeJwt returns null', async () => {
195
+ jwt.decodeJwt.mockReturnValue(null);
196
+
197
+ const result = await updateCallLog.execute({
198
+ jwtToken: 'invalid-token',
199
+ incomingData: {
200
+ logData: { sessionId: 'session-123' }
201
+ }
202
+ });
203
+
204
+ expect(result.success).toBe(false);
205
+ expect(result.error).toContain('Invalid JWT token');
206
+ });
207
+
194
208
  test('should return error when platform connector not found', async () => {
195
209
  // Arrange
196
210
  const mockIncomingData = {
@@ -0,0 +1,132 @@
1
+ const express = require('express');
2
+ const request = require('supertest');
3
+
4
+ jest.mock('../../handlers/admin', () => ({
5
+ validateRcUserToken: jest.fn(),
6
+ validateAdminRole: jest.fn(),
7
+ }));
8
+ jest.mock('../../handlers/managedAuth', () => ({
9
+ getManagedAuthState: jest.fn(),
10
+ }));
11
+ jest.mock('../../handlers/auth', () => ({
12
+ onApiKeyLogin: jest.fn(),
13
+ }));
14
+ jest.mock('../../lib/jwt', () => ({
15
+ generateJwt: jest.fn().mockReturnValue('jwt-token'),
16
+ decodeJwt: jest.fn(),
17
+ }));
18
+
19
+ const adminCore = require('../../handlers/admin');
20
+ const managedAuthCore = require('../../handlers/managedAuth');
21
+ const authCore = require('../../handlers/auth');
22
+ const { createCoreRouter } = require('../../index');
23
+
24
+ describe('Managed Auth Routes', () => {
25
+ let app;
26
+
27
+ beforeEach(() => {
28
+ jest.clearAllMocks();
29
+ app = express();
30
+ app.use(express.json());
31
+ app.use('/', createCoreRouter());
32
+ });
33
+
34
+ describe('GET /apiKeyManagedAuthState', () => {
35
+ test('should require rcAccessToken', async () => {
36
+ const response = await request(app)
37
+ .get('/apiKeyManagedAuthState')
38
+ .query({ platform: 'testCRM' });
39
+
40
+ expect(response.status).toBe(400);
41
+ expect(response.text).toContain('Missing RingCentral access token');
42
+ expect(adminCore.validateRcUserToken).not.toHaveBeenCalled();
43
+ expect(managedAuthCore.getManagedAuthState).not.toHaveBeenCalled();
44
+ });
45
+
46
+ test('should validate rcAccessToken and use validated identity', async () => {
47
+ adminCore.validateRcUserToken.mockResolvedValue({
48
+ rcAccountId: 'validated-account-id',
49
+ rcExtensionId: 'validated-extension-id',
50
+ rcUserName: 'Validated User',
51
+ });
52
+ managedAuthCore.getManagedAuthState.mockResolvedValue({
53
+ hasManagedAuth: true,
54
+ allRequiredFieldsSatisfied: true,
55
+ visibleFieldConsts: [],
56
+ missingRequiredFieldConsts: [],
57
+ });
58
+
59
+ const response = await request(app)
60
+ .get('/apiKeyManagedAuthState')
61
+ .query({
62
+ platform: 'testCRM',
63
+ rcAccessToken: 'valid-rc-token',
64
+ rcAccountId: 'spoofed-account-id',
65
+ rcExtensionId: 'spoofed-extension-id',
66
+ });
67
+
68
+ expect(response.status).toBe(200);
69
+ expect(adminCore.validateRcUserToken).toHaveBeenCalledWith({ rcAccessToken: 'valid-rc-token' });
70
+ expect(managedAuthCore.getManagedAuthState).toHaveBeenCalledWith(expect.objectContaining({
71
+ platform: 'testCRM',
72
+ rcAccountId: 'validated-account-id',
73
+ rcExtensionId: 'validated-extension-id',
74
+ }));
75
+ });
76
+ });
77
+
78
+ describe('POST /apiKeyLogin', () => {
79
+ test('should require rcAccessToken', async () => {
80
+ const response = await request(app)
81
+ .post('/apiKeyLogin')
82
+ .send({
83
+ platform: 'testCRM',
84
+ apiKey: 'api-key',
85
+ hostname: 'test.example.com',
86
+ });
87
+
88
+ expect(response.status).toBe(400);
89
+ expect(response.text).toContain('Missing RingCentral access token');
90
+ expect(adminCore.validateRcUserToken).not.toHaveBeenCalled();
91
+ expect(authCore.onApiKeyLogin).not.toHaveBeenCalled();
92
+ });
93
+
94
+ test('should validate rcAccessToken and ignore spoofed rc ids in body', async () => {
95
+ adminCore.validateRcUserToken.mockResolvedValue({
96
+ rcAccountId: 'validated-account-id',
97
+ rcExtensionId: 'validated-extension-id',
98
+ rcUserName: 'Validated User',
99
+ });
100
+ authCore.onApiKeyLogin.mockResolvedValue({
101
+ userInfo: {
102
+ id: 'crm-user-id',
103
+ name: 'CRM User',
104
+ },
105
+ returnMessage: {
106
+ messageType: 'success',
107
+ message: 'ok',
108
+ },
109
+ });
110
+
111
+ const response = await request(app)
112
+ .post('/apiKeyLogin')
113
+ .send({
114
+ platform: 'testCRM',
115
+ apiKey: 'api-key',
116
+ hostname: 'test.example.com',
117
+ rcAccessToken: 'valid-rc-token',
118
+ rcAccountId: 'spoofed-account-id',
119
+ rcExtensionId: 'spoofed-extension-id',
120
+ });
121
+
122
+ expect(response.status).toBe(200);
123
+ expect(adminCore.validateRcUserToken).toHaveBeenCalledWith({ rcAccessToken: 'valid-rc-token' });
124
+ expect(authCore.onApiKeyLogin).toHaveBeenCalledWith(expect.objectContaining({
125
+ platform: 'testCRM',
126
+ rcAccountId: 'validated-account-id',
127
+ rcExtensionId: 'validated-extension-id',
128
+ rcUserName: 'Validated User',
129
+ }));
130
+ });
131
+ });
132
+ });
package/test/setup.js CHANGED
@@ -29,6 +29,7 @@ beforeAll(async () => {
29
29
  const { UserModel } = require('../models/userModel');
30
30
  const { CacheModel } = require('../models/cacheModel');
31
31
  const { AdminConfigModel } = require('../models/adminConfigModel');
32
+ const { AccountDataModel } = require('../models/accountDataModel');
32
33
 
33
34
  // Sync database models
34
35
  await CallLogModel.sync({ force: true });
@@ -36,6 +37,7 @@ beforeAll(async () => {
36
37
  await UserModel.sync({ force: true });
37
38
  await CacheModel.sync({ force: true });
38
39
  await AdminConfigModel.sync({ force: true });
40
+ await AccountDataModel.sync({ force: true });
39
41
 
40
42
  console.log('Database models synced for testing');
41
43
  } catch (error) {