@app-connect/core 1.7.21 → 1.7.23

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 (50) 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 +57 -10
  13. package/handlers/log.js +217 -109
  14. package/handlers/managedAuth.js +446 -0
  15. package/handlers/plugin.js +183 -1
  16. package/handlers/user.js +1 -1
  17. package/index.js +410 -35
  18. package/lib/callLogComposer.js +36 -36
  19. package/lib/jwt.js +1 -1
  20. package/lib/util.js +0 -18
  21. package/mcp/tools/createCallLog.js +5 -1
  22. package/mcp/tools/createContact.js +5 -1
  23. package/mcp/tools/createMessageLog.js +5 -1
  24. package/mcp/tools/findContactByName.js +5 -1
  25. package/mcp/tools/findContactByPhone.js +6 -2
  26. package/mcp/tools/getCallLog.js +5 -1
  27. package/mcp/tools/rcGetCallLogs.js +6 -2
  28. package/mcp/tools/updateCallLog.js +5 -1
  29. package/mcp/ui/App/lib/developerPortal.ts +1 -1
  30. package/package.json +72 -72
  31. package/releaseNotes.json +16 -0
  32. package/test/handlers/admin.test.js +33 -0
  33. package/test/handlers/auth.test.js +402 -6
  34. package/test/handlers/log.test.js +60 -0
  35. package/test/handlers/managedAuth.test.js +458 -0
  36. package/test/handlers/plugin.test.js +93 -0
  37. package/test/index.test.js +105 -0
  38. package/test/lib/callLogComposer.test.js +21 -21
  39. package/test/lib/jwt.test.js +15 -0
  40. package/test/lib/util.test.js +1 -332
  41. package/test/mcp/tools/createCallLog.test.js +11 -0
  42. package/test/mcp/tools/createContact.test.js +58 -0
  43. package/test/mcp/tools/createMessageLog.test.js +15 -0
  44. package/test/mcp/tools/findContactByName.test.js +12 -0
  45. package/test/mcp/tools/findContactByPhone.test.js +12 -0
  46. package/test/mcp/tools/getCallLog.test.js +12 -0
  47. package/test/mcp/tools/rcGetCallLogs.test.js +56 -0
  48. package/test/mcp/tools/updateCallLog.test.js +14 -0
  49. package/test/routes/managedAuthRoutes.test.js +129 -0
  50. package/test/setup.js +2 -0
@@ -22,6 +22,8 @@ const oauth = require('../../lib/oauth');
22
22
  const { Connector } = require('../../models/dynamo/connectorSchema');
23
23
  const { RingCentral } = require('../../lib/ringcentral');
24
24
  const adminCore = require('../../handlers/admin');
25
+ const { AccountDataModel } = require('../../models/accountDataModel');
26
+ const { encode } = require('../../lib/encode');
25
27
 
26
28
  describe('Auth Handler', () => {
27
29
  const originalEnv = process.env;
@@ -31,6 +33,7 @@ describe('Auth Handler', () => {
31
33
  jest.clearAllMocks();
32
34
  global.testUtils.resetConnectorRegistry();
33
35
  process.env = { ...originalEnv };
36
+ process.env.APP_SERVER_SECRET_KEY = 'test-app-server-secret-key-123456';
34
37
  });
35
38
 
36
39
  afterEach(() => {
@@ -38,6 +41,10 @@ describe('Auth Handler', () => {
38
41
  });
39
42
 
40
43
  describe('onApiKeyLogin', () => {
44
+ afterEach(async () => {
45
+ await AccountDataModel.destroy({ where: {} });
46
+ });
47
+
41
48
  test('should handle successful API key login', async () => {
42
49
  // Arrange
43
50
  const mockUserInfo = {
@@ -60,7 +67,7 @@ describe('Auth Handler', () => {
60
67
  getBasicAuth: jest.fn().mockReturnValue('dGVzdC1hcGkta2V5Og=='),
61
68
  getUserInfo: jest.fn().mockResolvedValue(mockUserInfo)
62
69
  });
63
-
70
+
64
71
  connectorRegistry.getConnector.mockReturnValue(mockConnector);
65
72
 
66
73
  const requestData = {
@@ -82,7 +89,7 @@ describe('Auth Handler', () => {
82
89
  expect(mockConnector.getUserInfo).toHaveBeenCalledWith({
83
90
  authHeader: 'Basic dGVzdC1hcGkta2V5Og==',
84
91
  hostname: 'test.example.com',
85
- additionalInfo: {},
92
+ additionalInfo: { apiKey: 'test-api-key' },
86
93
  apiKey: 'test-api-key',
87
94
  platform: 'testCRM',
88
95
  proxyId: undefined
@@ -105,7 +112,7 @@ describe('Auth Handler', () => {
105
112
  getBasicAuth: jest.fn().mockReturnValue('dGVzdC1hcGkta2V5Og=='),
106
113
  getUserInfo: jest.fn().mockResolvedValue(mockUserInfo)
107
114
  });
108
-
115
+
109
116
  connectorRegistry.getConnector.mockReturnValue(mockConnector);
110
117
 
111
118
  const requestData = {
@@ -123,6 +130,394 @@ describe('Auth Handler', () => {
123
130
  expect(result.returnMessage).toEqual(mockUserInfo.returnMessage);
124
131
  });
125
132
 
133
+ test('should mark managed auth auto-login failure so the next attempt can fall back to manual auth', async () => {
134
+ connectorRegistry.getManifest.mockReturnValue({
135
+ platforms: {
136
+ testCRM: {
137
+ auth: {
138
+ type: 'apiKey',
139
+ apiKey: {
140
+ page: {
141
+ content: [
142
+ { const: 'tenantId', required: true, managed: true, managedScope: 'account' },
143
+ { const: 'apiKey', required: true, managed: true, managedScope: 'user' }
144
+ ]
145
+ }
146
+ }
147
+ }
148
+ }
149
+ }
150
+ });
151
+
152
+ await AccountDataModel.create({
153
+ rcAccountId: 'rc-account-fail',
154
+ platformName: 'testCRM',
155
+ dataKey: 'managed-auth-org',
156
+ data: {
157
+ fields: {
158
+ tenantId: { version: 1, encrypted: true, value: encode(JSON.stringify('tenant-1')) }
159
+ }
160
+ }
161
+ });
162
+ await AccountDataModel.create({
163
+ rcAccountId: 'rc-account-fail',
164
+ platformName: 'testCRM',
165
+ dataKey: 'managed-auth-user:101',
166
+ data: {
167
+ rcExtensionId: '101',
168
+ rcUserName: 'Agent 101',
169
+ fields: {
170
+ apiKey: { version: 1, encrypted: true, value: encode(JSON.stringify('bad-stored-key')) }
171
+ }
172
+ }
173
+ });
174
+
175
+ const mockConnector = global.testUtils.createMockConnector({
176
+ getBasicAuth: jest.fn().mockReturnValue('encoded-bad-key'),
177
+ getUserInfo: jest.fn().mockResolvedValue({
178
+ successful: false,
179
+ platformUserInfo: null,
180
+ returnMessage: {
181
+ messageType: 'error',
182
+ message: 'Invalid API key',
183
+ ttl: 3000
184
+ }
185
+ })
186
+ });
187
+ connectorRegistry.getConnector.mockReturnValue(mockConnector);
188
+
189
+ const result = await authHandler.onApiKeyLogin({
190
+ platform: 'testCRM',
191
+ hostname: 'test.example.com',
192
+ rcAccountId: 'rc-account-fail',
193
+ rcExtensionId: '101',
194
+ additionalInfo: {}
195
+ });
196
+
197
+ expect(result.userInfo).toBeNull();
198
+ const failureRecord = await AccountDataModel.findOne({
199
+ where: {
200
+ rcAccountId: 'rc-account-fail',
201
+ platformName: 'testCRM',
202
+ dataKey: 'managed-auth-login-failure:101'
203
+ }
204
+ });
205
+ expect(failureRecord).not.toBeNull();
206
+ });
207
+
208
+ test('should merge stored org managed auth values into additionalInfo', async () => {
209
+ connectorRegistry.getManifest.mockReturnValue({
210
+ platforms: {
211
+ testCRM: {
212
+ auth: {
213
+ type: 'apiKey',
214
+ apiKey: {
215
+ page: {
216
+ content: [
217
+ { const: 'apiKey', required: true, managed: true, managedScope: 'account' },
218
+ { const: 'tenantId', required: true, managed: true, managedScope: 'account' },
219
+ { const: 'userToken', required: true }
220
+ ]
221
+ }
222
+ }
223
+ }
224
+ }
225
+ }
226
+ });
227
+ await AccountDataModel.create({
228
+ rcAccountId: 'rc-account-1',
229
+ platformName: 'testCRM',
230
+ dataKey: 'managed-auth-org',
231
+ data: {
232
+ fields: {
233
+ apiKey: { version: 1, encrypted: true, value: encode(JSON.stringify('stored-api-key')) },
234
+ tenantId: { version: 1, encrypted: true, value: encode(JSON.stringify('tenant-1')) }
235
+ }
236
+ }
237
+ });
238
+
239
+ const mockUserInfo = {
240
+ successful: true,
241
+ platformUserInfo: {
242
+ id: 'test-user-id',
243
+ name: 'Test User',
244
+ platformAdditionalInfo: {}
245
+ },
246
+ returnMessage: { messageType: 'success', message: 'ok' }
247
+ };
248
+ const mockConnector = global.testUtils.createMockConnector({
249
+ getBasicAuth: jest.fn().mockReturnValue('encoded-shared'),
250
+ getUserInfo: jest.fn().mockResolvedValue(mockUserInfo)
251
+ });
252
+ connectorRegistry.getConnector.mockReturnValue(mockConnector);
253
+
254
+ await authHandler.onApiKeyLogin({
255
+ platform: 'testCRM',
256
+ hostname: 'test.example.com',
257
+ rcAccountId: 'rc-account-1',
258
+ additionalInfo: { userToken: 'user-token-1' }
259
+ });
260
+
261
+ expect(mockConnector.getBasicAuth).toHaveBeenCalledWith({ apiKey: 'stored-api-key' });
262
+ expect(mockConnector.getUserInfo).toHaveBeenCalledWith(expect.objectContaining({
263
+ additionalInfo: expect.objectContaining({
264
+ apiKey: 'stored-api-key',
265
+ tenantId: 'tenant-1',
266
+ userToken: 'user-token-1'
267
+ })
268
+ }));
269
+ });
270
+
271
+ test('should allow submitted shared fields to satisfy missing required managed auth values', async () => {
272
+ connectorRegistry.getManifest.mockReturnValue({
273
+ platforms: {
274
+ testCRM: {
275
+ auth: {
276
+ type: 'apiKey',
277
+ apiKey: {
278
+ page: {
279
+ content: [
280
+ { const: 'companyId', required: true, managed: true, managedScope: 'account' },
281
+ { const: 'userToken', required: true }
282
+ ]
283
+ }
284
+ }
285
+ }
286
+ }
287
+ }
288
+ });
289
+
290
+ const mockConnector = global.testUtils.createMockConnector({
291
+ getBasicAuth: jest.fn(),
292
+ getUserInfo: jest.fn().mockResolvedValue({
293
+ successful: true,
294
+ platformUserInfo: {
295
+ id: 'test-user-id',
296
+ name: 'Test User',
297
+ platformAdditionalInfo: {}
298
+ },
299
+ returnMessage: { messageType: 'success', message: 'ok' }
300
+ })
301
+ });
302
+ connectorRegistry.getConnector.mockReturnValue(mockConnector);
303
+
304
+ const result = await authHandler.onApiKeyLogin({
305
+ platform: 'testCRM',
306
+ hostname: 'test.example.com',
307
+ rcAccountId: 'rc-account-2',
308
+ additionalInfo: {
309
+ companyId: 'company-123',
310
+ userToken: 'user-token-1'
311
+ }
312
+ });
313
+
314
+ expect(result.userInfo).not.toBeNull();
315
+ expect(mockConnector.getUserInfo).toHaveBeenCalledWith(expect.objectContaining({
316
+ additionalInfo: expect.objectContaining({
317
+ companyId: 'company-123',
318
+ userToken: 'user-token-1'
319
+ })
320
+ }));
321
+ });
322
+
323
+ test('should not persist submitted managed auth values from end users', async () => {
324
+ connectorRegistry.getManifest.mockReturnValue({
325
+ platforms: {
326
+ testCRM: {
327
+ auth: {
328
+ type: 'apiKey',
329
+ apiKey: {
330
+ page: {
331
+ content: [
332
+ { const: 'companyId', required: false, managed: true, managedScope: 'account' },
333
+ { const: 'userToken', required: true }
334
+ ]
335
+ }
336
+ }
337
+ }
338
+ }
339
+ }
340
+ });
341
+
342
+ const mockUserInfo = {
343
+ successful: true,
344
+ platformUserInfo: {
345
+ id: 'test-user-id',
346
+ name: 'Test User',
347
+ platformAdditionalInfo: {}
348
+ },
349
+ returnMessage: { messageType: 'success', message: 'ok' }
350
+ };
351
+ const mockConnector = global.testUtils.createMockConnector({
352
+ getBasicAuth: jest.fn().mockReturnValue('encoded'),
353
+ getUserInfo: jest.fn().mockResolvedValue(mockUserInfo)
354
+ });
355
+ connectorRegistry.getConnector.mockReturnValue(mockConnector);
356
+
357
+ await authHandler.onApiKeyLogin({
358
+ platform: 'testCRM',
359
+ hostname: 'test.example.com',
360
+ rcAccountId: 'rc-account-2',
361
+ additionalInfo: {
362
+ companyId: 'company-123',
363
+ userToken: 'user-token-1'
364
+ }
365
+ });
366
+
367
+ expect(mockConnector.getUserInfo).toHaveBeenCalledWith(expect.objectContaining({
368
+ additionalInfo: expect.objectContaining({
369
+ companyId: 'company-123',
370
+ userToken: 'user-token-1'
371
+ })
372
+ }));
373
+
374
+ const stored = await AccountDataModel.findOne({
375
+ where: {
376
+ rcAccountId: 'rc-account-2',
377
+ platformName: 'testCRM',
378
+ dataKey: 'managed-auth-org'
379
+ }
380
+ });
381
+ expect(stored).toBeNull();
382
+ });
383
+
384
+ test('should allow manual fallback values to override stored managed credentials and clear failure state after success', async () => {
385
+ connectorRegistry.getManifest.mockReturnValue({
386
+ platforms: {
387
+ testCRM: {
388
+ auth: {
389
+ type: 'apiKey',
390
+ apiKey: {
391
+ page: {
392
+ content: [
393
+ { const: 'apiKey', required: true, managed: true, managedScope: 'user' },
394
+ { const: 'tenantId', required: true, managed: true, managedScope: 'account' }
395
+ ]
396
+ }
397
+ }
398
+ }
399
+ }
400
+ }
401
+ });
402
+
403
+ await AccountDataModel.create({
404
+ rcAccountId: 'rc-account-recover',
405
+ platformName: 'testCRM',
406
+ dataKey: 'managed-auth-org',
407
+ data: {
408
+ fields: {
409
+ tenantId: { version: 1, encrypted: true, value: encode(JSON.stringify('stored-tenant')) }
410
+ }
411
+ }
412
+ });
413
+ await AccountDataModel.create({
414
+ rcAccountId: 'rc-account-recover',
415
+ platformName: 'testCRM',
416
+ dataKey: 'managed-auth-user:202',
417
+ data: {
418
+ rcExtensionId: '202',
419
+ rcUserName: 'Agent 202',
420
+ fields: {
421
+ apiKey: { version: 1, encrypted: true, value: encode(JSON.stringify('stored-bad-key')) }
422
+ }
423
+ }
424
+ });
425
+ await AccountDataModel.create({
426
+ rcAccountId: 'rc-account-recover',
427
+ platformName: 'testCRM',
428
+ dataKey: 'managed-auth-login-failure:202',
429
+ data: {
430
+ failedAt: '2026-04-07T00:00:00.000Z'
431
+ }
432
+ });
433
+
434
+ const mockConnector = global.testUtils.createMockConnector({
435
+ getBasicAuth: jest.fn().mockReturnValue('encoded-manual-key'),
436
+ getUserInfo: jest.fn().mockResolvedValue({
437
+ successful: true,
438
+ platformUserInfo: {
439
+ id: 'test-user-id',
440
+ name: 'Recovered User',
441
+ platformAdditionalInfo: {}
442
+ },
443
+ returnMessage: { messageType: 'success', message: 'ok' }
444
+ })
445
+ });
446
+ connectorRegistry.getConnector.mockReturnValue(mockConnector);
447
+
448
+ const result = await authHandler.onApiKeyLogin({
449
+ platform: 'testCRM',
450
+ hostname: 'test.example.com',
451
+ rcAccountId: 'rc-account-recover',
452
+ rcExtensionId: '202',
453
+ additionalInfo: {
454
+ apiKey: 'manual-good-key',
455
+ tenantId: 'manual-tenant'
456
+ }
457
+ });
458
+
459
+ expect(result.userInfo).not.toBeNull();
460
+ expect(mockConnector.getBasicAuth).toHaveBeenCalledWith({ apiKey: 'manual-good-key' });
461
+ expect(mockConnector.getUserInfo).toHaveBeenCalledWith(expect.objectContaining({
462
+ additionalInfo: {
463
+ apiKey: 'manual-good-key',
464
+ tenantId: 'manual-tenant'
465
+ }
466
+ }));
467
+
468
+ const failureRecord = await AccountDataModel.findOne({
469
+ where: {
470
+ rcAccountId: 'rc-account-recover',
471
+ platformName: 'testCRM',
472
+ dataKey: 'managed-auth-login-failure:202'
473
+ }
474
+ });
475
+ expect(failureRecord).toBeNull();
476
+ });
477
+
478
+ test('should return warning when required auth fields are missing', async () => {
479
+ connectorRegistry.getManifest.mockReturnValue({
480
+ platforms: {
481
+ testCRM: {
482
+ auth: {
483
+ type: 'apiKey',
484
+ apiKey: {
485
+ page: {
486
+ content: [
487
+ { const: 'tenantId', required: true, managed: true, managedScope: 'account' },
488
+ { const: 'userToken', required: true }
489
+ ]
490
+ }
491
+ }
492
+ }
493
+ }
494
+ }
495
+ });
496
+
497
+ const mockConnector = global.testUtils.createMockConnector({
498
+ getBasicAuth: jest.fn(),
499
+ getUserInfo: jest.fn()
500
+ });
501
+ connectorRegistry.getConnector.mockReturnValue(mockConnector);
502
+
503
+ const result = await authHandler.onApiKeyLogin({
504
+ platform: 'testCRM',
505
+ hostname: 'test.example.com',
506
+ rcAccountId: 'rc-account-4',
507
+ additionalInfo: {}
508
+ });
509
+
510
+ expect(result.userInfo).toBeNull();
511
+ expect(result.returnMessage).toEqual({
512
+ messageType: 'warning',
513
+ message: 'Missing required authentication fields.',
514
+ ttl: 3000,
515
+ missingRequiredFieldConsts: ['tenantId', 'userToken']
516
+ });
517
+ expect(mockConnector.getBasicAuth).not.toHaveBeenCalled();
518
+ expect(mockConnector.getUserInfo).not.toHaveBeenCalled();
519
+ });
520
+
126
521
  test('should throw error when connector not found', async () => {
127
522
  // Arrange
128
523
  connectorRegistry.getConnector.mockImplementation(() => {
@@ -160,7 +555,7 @@ describe('Auth Handler', () => {
160
555
  getOauthInfo: jest.fn().mockResolvedValue({}),
161
556
  authValidation: jest.fn().mockResolvedValue(mockValidationResponse)
162
557
  });
163
-
558
+
164
559
  connectorRegistry.getConnector.mockReturnValue(mockConnector);
165
560
 
166
561
  // Mock UserModel.findOne to return a user
@@ -229,7 +624,7 @@ describe('Auth Handler', () => {
229
624
  getOauthInfo: jest.fn().mockResolvedValue({}),
230
625
  authValidation: jest.fn().mockResolvedValue(mockValidationResponse)
231
626
  });
232
-
627
+
233
628
  connectorRegistry.getConnector.mockReturnValue(mockConnector);
234
629
 
235
630
  // Mock UserModel.findOne to return a user
@@ -616,4 +1011,5 @@ describe('Auth Handler', () => {
616
1011
  expect(RingCentral).not.toHaveBeenCalled();
617
1012
  });
618
1013
  });
619
- });
1014
+ });
1015
+
@@ -24,6 +24,7 @@ jest.mock('../../models/dynamo/connectorSchema', () => ({
24
24
  getProxyConfig: jest.fn()
25
25
  }
26
26
  }));
27
+ jest.mock('axios');
27
28
 
28
29
  const logHandler = require('../../handlers/log');
29
30
  const { CallLogModel } = require('../../models/callLogModel');
@@ -34,6 +35,7 @@ const connectorRegistry = require('../../connector/registry');
34
35
  const oauth = require('../../lib/oauth');
35
36
  const { composeCallLog } = require('../../lib/callLogComposer');
36
37
  const { NoteCache } = require('../../models/dynamo/noteCacheSchema');
38
+ const axios = require('axios');
37
39
  const { sequelize } = require('../../models/sequelize');
38
40
 
39
41
  describe('Log Handler', () => {
@@ -61,6 +63,7 @@ describe('Log Handler', () => {
61
63
  id: 'test-user-id',
62
64
  platform: 'testCRM',
63
65
  accessToken: 'test-access-token',
66
+ rcAccountId: '12345',
64
67
  platformAdditionalInfo: {}
65
68
  };
66
69
 
@@ -217,6 +220,63 @@ describe('Log Handler', () => {
217
220
  expect(savedLog.thirdPartyLogId).toBe('new-log-123');
218
221
  });
219
222
 
223
+ test('should call plugin with Bearer auth and without query jwt token', async () => {
224
+ await UserModel.create(mockUser);
225
+ await AccountDataModel.create({
226
+ rcAccountId: mockUser.rcAccountId,
227
+ platformName: 'testPlugin',
228
+ dataKey: 'pluginData',
229
+ data: {
230
+ name: 'plugin.sample',
231
+ supportedLogTypes: ['call'],
232
+ isAsync: false,
233
+ endpointUrl: 'https://plugins.example.com/plugin/testPlugin',
234
+ jwtToken: 'plugin-jwt-token'
235
+ }
236
+ });
237
+
238
+ const mockConnector = {
239
+ getAuthType: jest.fn().mockResolvedValue('apiKey'),
240
+ getBasicAuth: jest.fn().mockReturnValue('base64-encoded'),
241
+ getLogFormatType: jest.fn().mockReturnValue('text/plain'),
242
+ createCallLog: jest.fn().mockResolvedValue({
243
+ logId: 'new-log-123',
244
+ returnMessage: { message: 'Call logged', messageType: 'success', ttl: 2000 }
245
+ })
246
+ };
247
+ connectorRegistry.getConnector.mockReturnValue(mockConnector);
248
+ composeCallLog.mockReturnValue('Composed log details');
249
+
250
+ axios.post.mockResolvedValue({
251
+ data: {
252
+ ...mockIncomingData,
253
+ note: 'updated by plugin'
254
+ },
255
+ headers: {
256
+ 'x-refreshed-jwt-token': 'refreshed-plugin-jwt'
257
+ }
258
+ });
259
+
260
+ const result = await logHandler.createCallLog({
261
+ platform: 'testCRM',
262
+ userId: 'test-user-id',
263
+ incomingData: mockIncomingData,
264
+ hashedAccountId: 'hashed-123',
265
+ isFromSSCL: false
266
+ });
267
+
268
+ expect(result.successful).toBe(true);
269
+ expect(axios.post).toHaveBeenCalledWith(
270
+ 'https://plugins.example.com/plugin/testPlugin',
271
+ { data: mockIncomingData, config: null },
272
+ {
273
+ headers: {
274
+ Authorization: 'Bearer plugin-jwt-token'
275
+ }
276
+ }
277
+ );
278
+ });
279
+
220
280
  test('should successfully create call log with oauth auth', async () => {
221
281
  // Arrange
222
282
  const oauthUser = { ...mockUser };