@app-connect/core 1.7.23 → 1.7.25

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.
@@ -12,21 +12,11 @@ async function getPublicConnectorList() {
12
12
  }
13
13
  }
14
14
 
15
- async function getPrivateConnectorList() {
16
- try {
17
- const response = await axios.get(`https://appconnect.labs.ringcentral.com/public-api/connectors/internal?accountId=${process.env.RC_ACCOUNT_ID}`);
18
- return response.data;
19
- } catch (error) {
20
- logger.error('Error getting private connector list:', error);
21
- return null;
22
- }
23
- }
24
-
25
- async function getConnectorManifest({ connectorId, isPrivate = false }) {
15
+ async function getConnectorManifest({ rcAccountId, connectorId, isPrivate = false }) {
26
16
  try {
27
17
  let response = null;
28
18
  if (isPrivate) {
29
- response = await axios.get(`https://appconnect.labs.ringcentral.com/public-api/connectors/${connectorId}/manifest?access=internal&type=connector&accountId=${process.env.RC_ACCOUNT_ID}`);
19
+ response = await axios.get(`https://appconnect.labs.ringcentral.com/public-api/connectors/${connectorId}/manifest?access=internal&type=connector&accountId=${rcAccountId}`);
30
20
  }
31
21
  else {
32
22
  response = await axios.get(`https://appconnect.labs.ringcentral.com/public-api/connectors/${connectorId}/manifest`);
@@ -39,5 +29,4 @@ async function getConnectorManifest({ connectorId, isPrivate = false }) {
39
29
  }
40
30
 
41
31
  exports.getPublicConnectorList = getPublicConnectorList;
42
- exports.getPrivateConnectorList = getPrivateConnectorList;
43
32
  exports.getConnectorManifest = getConnectorManifest;
@@ -79,9 +79,10 @@ function buildHeaders({ config, operation, authHeader, context }) {
79
79
  async function performRequest({ config, opName, inputs, user, authHeader }) {
80
80
  const op = config.operations?.[opName];
81
81
  if (!op) return null;
82
+ const accessToken = user?.accessToken ?? inputs?.apiKey ?? '';
82
83
  const context = Object.assign({}, inputs, {
83
84
  user: user ? {
84
- accessToken: user.accessToken,
85
+ accessToken,
85
86
  id: user.id?.split('-')[0],
86
87
  hostname: user.hostname,
87
88
  timezoneName: user.timezoneName,
@@ -91,10 +92,10 @@ async function performRequest({ config, opName, inputs, user, authHeader }) {
91
92
  refreshToken: user.refreshToken,
92
93
  tokenExpiry: user.tokenExpiry,
93
94
  } : {
94
- accessToken: '',
95
+ accessToken,
95
96
  },
96
97
  authHeader,
97
- apiKey: user?.accessToken,
98
+ apiKey: accessToken,
98
99
  secretKey: config.secretKey,
99
100
  });
100
101
  const url = joinUrl(config.requestDefaults?.baseUrl, renderTemplateString(op.url, context));
@@ -61,7 +61,6 @@ The proxy connector makes integrations configurable through stored connector met
61
61
  Exports:
62
62
 
63
63
  - `getPublicConnectorList()`
64
- - `getPrivateConnectorList()`
65
64
  - `getConnectorManifest()`
66
65
 
67
66
  These functions are small fetch helpers and return `null` on failure after logging.
package/handlers/admin.js CHANGED
@@ -6,6 +6,7 @@ const { RingCentral } = require('../lib/ringcentral');
6
6
  const { Connector } = require('../models/dynamo/connectorSchema');
7
7
  const logger = require('../lib/logger');
8
8
  const { handleDatabaseError } = require('../lib/errorHandler');
9
+ const { getHashValue } = require('../lib/util');
9
10
 
10
11
  const CALL_AGGREGATION_GROUPS = ["Company", "CompanyNumbers", "Users", "Queues", "IVRs", "IVAs", "SharedLines", "UserGroups", "Sites", "Departments"]
11
12
  const RC_EXTENSION_ENDPOINT = 'https://platform.ringcentral.com/restapi/v1.0/account/~/extension/~';
@@ -108,7 +109,8 @@ async function getAdminReport({ rcAccountId, timezone, timeFrom, timeTo, groupBy
108
109
  clientSecret: process.env.RINGCENTRAL_CLIENT_SECRET,
109
110
  redirectUri: `${process.env.APP_SERVER}/ringcentral/oauth/callback`
110
111
  });
111
- let adminConfig = await AdminConfigModel.findByPk(rcAccountId);
112
+ const hashedRcAccountId = getHashValue(rcAccountId, process.env.HASH_KEY);
113
+ let adminConfig = await AdminConfigModel.findByPk(hashedRcAccountId);
112
114
  const isTokenExpired = adminConfig.adminTokenExpiry < new Date();
113
115
  if (isTokenExpired) {
114
116
  const { access_token, refresh_token, expire_time } = await rcSDK.refreshToken({
@@ -116,7 +118,7 @@ async function getAdminReport({ rcAccountId, timezone, timeFrom, timeTo, groupBy
116
118
  expires_in: adminConfig.adminTokenExpiry,
117
119
  refresh_token_expires_in: adminConfig.adminTokenExpiry
118
120
  });
119
- adminConfig = await AdminConfigModel.update({ adminAccessToken: access_token, adminRefreshToken: refresh_token, adminTokenExpiry: expire_time }, { where: { id: rcAccountId } });
121
+ adminConfig = await AdminConfigModel.update({ adminAccessToken: access_token, adminRefreshToken: refresh_token, adminTokenExpiry: expire_time }, { where: { id: hashedRcAccountId } });
120
122
  }
121
123
  const callsAggregationData = await rcSDK.getCallsAggregationData({
122
124
  token: { access_token: adminConfig.adminAccessToken, token_type: 'Bearer' },
@@ -177,7 +179,8 @@ async function getUserReport({ rcAccountId, rcExtensionId, timezone, timeFrom, t
177
179
  clientSecret: process.env.RINGCENTRAL_CLIENT_SECRET,
178
180
  redirectUri: `${process.env.APP_SERVER}/ringcentral/oauth/callback`
179
181
  });
180
- let adminConfig = await AdminConfigModel.findByPk(rcAccountId);
182
+ const hashedRcAccountId = getHashValue(rcAccountId, process.env.HASH_KEY);
183
+ let adminConfig = await AdminConfigModel.findByPk(hashedRcAccountId);
181
184
  const isTokenExpired = adminConfig.adminTokenExpiry < new Date();
182
185
  if (isTokenExpired) {
183
186
  const { access_token, refresh_token, expire_time } = await rcSDK.refreshToken({
@@ -185,7 +188,7 @@ async function getUserReport({ rcAccountId, rcExtensionId, timezone, timeFrom, t
185
188
  expires_in: adminConfig.adminTokenExpiry,
186
189
  refresh_token_expires_in: adminConfig.adminTokenExpiry
187
190
  });
188
- adminConfig = await AdminConfigModel.update({ adminAccessToken: access_token, adminRefreshToken: refresh_token, adminTokenExpiry: expire_time }, { where: { id: rcAccountId } });
191
+ adminConfig = await AdminConfigModel.update({ adminAccessToken: access_token, adminRefreshToken: refresh_token, adminTokenExpiry: expire_time }, { where: { id: hashedRcAccountId } });
189
192
  }
190
193
  const callLogData = await rcSDK.getCallLogData({
191
194
  extensionId: rcExtensionId,
package/handlers/auth.js CHANGED
@@ -7,6 +7,7 @@ const adminCore = require('./admin');
7
7
  const { Connector } = require('../models/dynamo/connectorSchema');
8
8
  const { handleDatabaseError } = require('../lib/errorHandler');
9
9
  const managedAuthCore = require('./managedAuth');
10
+ const { getHashValue } = require('../lib/util');
10
11
 
11
12
  async function onOAuthCallback({ platform, hostname, tokenUrl, query, hashedRcExtensionId, isFromMCP = false }) {
12
13
  const callbackUri = query.callbackUri;
@@ -89,7 +90,7 @@ async function onApiKeyLogin({ platform, hostname, apiKey, proxyId, rcAccountId,
89
90
  let resolvedApiKey = apiKey;
90
91
  let managedFieldDefinitions = [];
91
92
  if (rcAccountId) {
92
- managedFieldDefinitions = await managedAuthCore.getManagedFieldDefinitions({ platform, connectorId, isPrivate });
93
+ managedFieldDefinitions = await managedAuthCore.getManagedFieldDefinitions({ rcAccountId, platform, connectorId, isPrivate });
93
94
  const shouldFallbackToManualAuth = managedFieldDefinitions.length > 0
94
95
  && await managedAuthCore.hasManagedAuthLoginFailure({ rcAccountId, platform, rcExtensionId });
95
96
  const managedAuthResult = await managedAuthCore.resolveApiKeyLoginFields({
@@ -279,8 +280,9 @@ async function onRingcentralOAuthCallback({ code, rcAccountId }) {
279
280
  redirectUri: `${process.env.APP_SERVER}/ringcentral/oauth/callback`
280
281
  });
281
282
  const { access_token, refresh_token, expire_time } = await rcSDK.generateToken({ code });
283
+ const hashedRcAccountId = getHashValue(rcAccountId, process.env.HASH_KEY);
282
284
  await adminCore.updateAdminRcTokens({
283
- hashedRcAccountId: rcAccountId,
285
+ hashedRcAccountId,
284
286
  adminAccessToken: access_token,
285
287
  adminRefreshToken: refresh_token,
286
288
  adminTokenExpiry: expire_time
@@ -20,12 +20,12 @@ function isFilled(value) {
20
20
  return value !== undefined && value !== null && value !== '';
21
21
  }
22
22
 
23
- async function getApiKeyFieldDefinitions({ platform, connectorId, isPrivate = false }) {
23
+ async function getApiKeyFieldDefinitions({ rcAccountId, platform, connectorId, isPrivate = false }) {
24
24
  if (!platform) {
25
25
  return [];
26
26
  }
27
27
  if (connectorId) {
28
- const manifest = await developerPortal.getConnectorManifest({ connectorId, isPrivate });
28
+ const manifest = await developerPortal.getConnectorManifest({ rcAccountId, connectorId, isPrivate });
29
29
  if (manifest?.platforms?.[platform]?.auth?.apiKey?.page?.content) {
30
30
  return manifest.platforms[platform].auth.apiKey.page.content;
31
31
  }
@@ -39,8 +39,8 @@ async function getApiKeyFieldDefinitions({ platform, connectorId, isPrivate = fa
39
39
  }
40
40
  }
41
41
 
42
- async function getManagedFieldDefinitions({ platform, connectorId, isPrivate = false }) {
43
- const fieldDefinitions = await getApiKeyFieldDefinitions({ platform, connectorId, isPrivate });
42
+ async function getManagedFieldDefinitions({ rcAccountId, platform, connectorId, isPrivate = false }) {
43
+ const fieldDefinitions = await getApiKeyFieldDefinitions({ rcAccountId, platform, connectorId, isPrivate });
44
44
  return fieldDefinitions.filter(field => field?.managed);
45
45
  }
46
46
 
@@ -247,7 +247,7 @@ function getStoredFieldValue({ value }) {
247
247
  }
248
248
 
249
249
  async function getManagedAuthAdminSettings({ platform, rcAccountId, connectorId, isPrivate = false }) {
250
- const fieldDefinitions = await getManagedFieldDefinitions({ platform, connectorId, isPrivate });
250
+ const fieldDefinitions = await getManagedFieldDefinitions({ rcAccountId, platform, connectorId, isPrivate });
251
251
  const orgFieldDefinitions = fieldDefinitions.filter(field => field.managedScope === 'account');
252
252
  const userFieldDefinitions = fieldDefinitions.filter(field => field.managedScope === 'user');
253
253
  const orgValues = await getOrgManagedAuthValues({ rcAccountId, platform });
@@ -307,7 +307,7 @@ async function getManagedAuthAdminSettings({ platform, rcAccountId, connectorId,
307
307
  }
308
308
 
309
309
  async function getManagedAuthState({ platform, rcAccountId, rcExtensionId, connectorId, isPrivate = false }) {
310
- const fieldDefinitions = await getApiKeyFieldDefinitions({ platform, connectorId, isPrivate });
310
+ const fieldDefinitions = await getApiKeyFieldDefinitions({ rcAccountId, platform, connectorId, isPrivate });
311
311
  const managedFieldDefinitions = fieldDefinitions.filter(field => field?.managed);
312
312
  const orgValues = await getOrgManagedAuthValues({ rcAccountId, platform });
313
313
  const userValues = await getUserManagedAuthValues({ rcAccountId, platform, rcExtensionId });
@@ -367,7 +367,7 @@ async function getManagedAuthState({ platform, rcAccountId, rcExtensionId, conne
367
367
  }
368
368
 
369
369
  async function resolveApiKeyLoginFields({ platform, rcAccountId, rcExtensionId, connectorId, isPrivate = false, apiKey, additionalInfo = {}, preferSubmittedValuesForManagedFields = false }) {
370
- const fieldDefinitions = await getApiKeyFieldDefinitions({ platform, connectorId, isPrivate });
370
+ const fieldDefinitions = await getApiKeyFieldDefinitions({ rcAccountId, platform, connectorId, isPrivate });
371
371
  const resolvedAdditionalInfo = {
372
372
  ...(additionalInfo ?? {})
373
373
  };
package/index.js CHANGED
@@ -89,6 +89,19 @@ async function initDB() {
89
89
  await UserModel.sync();
90
90
  logger.info('hashedRcExtensionId column added to users table');
91
91
  }
92
+
93
+ // if LlmSessionModel doesn't have expiry column, add it
94
+ const llmSessionTableName = LlmSessionModel.getTableName();
95
+ const llmSessionTableSchema = await queryInterface.describeTable(llmSessionTableName);
96
+ if (!llmSessionTableSchema.expiry) {
97
+ logger.info('adding expiry column to llmSessions table...');
98
+ await queryInterface.addColumn(llmSessionTableName, 'expiry', {
99
+ type: Sequelize.DATE,
100
+ allowNull: true,
101
+ });
102
+ await LlmSessionModel.sync();
103
+ logger.info('expiry column added to llmSessions table');
104
+ }
92
105
  }
93
106
  }
94
107
 
@@ -1165,13 +1178,13 @@ function createCoreRouter() {
1165
1178
  res.status(400).send(tracer ? tracer.wrapResponse('Missing platform name') : 'Missing platform name');
1166
1179
  return;
1167
1180
  }
1168
- if (!rcAccessToken) {
1169
- res.status(400).send(tracer ? tracer.wrapResponse('Missing RingCentral access token') : 'Missing RingCentral access token');
1170
- return;
1181
+ let rcAccountId = null;
1182
+ let rcExtensionId = null;
1183
+ if (rcAccessToken) {
1184
+ const rcUserTokenResult = await adminCore.validateRcUserToken({ rcAccessToken });
1185
+ rcAccountId = rcUserTokenResult.rcAccountId;
1186
+ rcExtensionId = rcUserTokenResult.rcExtensionId;
1171
1187
  }
1172
- const rcUserTokenResult = await adminCore.validateRcUserToken({ rcAccessToken });
1173
- const rcAccountId = rcUserTokenResult.rcAccountId;
1174
- const rcExtensionId = rcUserTokenResult.rcExtensionId;
1175
1188
  const { userInfo, returnMessage } = await authCore.onApiKeyLogin({
1176
1189
  platform,
1177
1190
  hostname,
@@ -7,7 +7,20 @@
7
7
  const { CacheModel } = require('../models/cacheModel');
8
8
 
9
9
  const AUTH_SESSION_PREFIX = 'auth-session';
10
- const SESSION_EXPIRY_MINUTES = 5;
10
+ const PENDING_SESSION_EXPIRY_MINUTES = 5;
11
+ const SETTLED_SESSION_EXPIRY_MINUTES = 15;
12
+
13
+ function getSessionRecordId(sessionId) {
14
+ return `${AUTH_SESSION_PREFIX}-${sessionId}`;
15
+ }
16
+
17
+ function getExpiry(minutes) {
18
+ return new Date(Date.now() + minutes * 60 * 1000);
19
+ }
20
+
21
+ function isExpired(record) {
22
+ return Boolean(record?.expiry && record.expiry <= new Date());
23
+ }
11
24
 
12
25
  /**
13
26
  * Create (or reset) an auth session.
@@ -16,9 +29,13 @@ const SESSION_EXPIRY_MINUTES = 5;
16
29
  * correctly for the new attempt.
17
30
  */
18
31
  async function createAuthSession(sessionId, data) {
19
- const id = `${AUTH_SESSION_PREFIX}-${sessionId}`;
20
- const expiry = new Date(Date.now() + SESSION_EXPIRY_MINUTES * 60 * 1000);
21
- const sessionData = { ...data, createdAt: new Date().toISOString() };
32
+ const id = getSessionRecordId(sessionId);
33
+ const expiry = getExpiry(PENDING_SESSION_EXPIRY_MINUTES);
34
+ const sessionData = {
35
+ platform: data.platform,
36
+ hostname: data.hostname || '',
37
+ createdAt: new Date().toISOString(),
38
+ };
22
39
 
23
40
  const existing = await CacheModel.findByPk(id);
24
41
  if (existing) {
@@ -39,10 +56,21 @@ async function createAuthSession(sessionId, data) {
39
56
  * Get an auth session by ID
40
57
  */
41
58
  async function getAuthSession(sessionId) {
42
- const record = await CacheModel.findByPk(`${AUTH_SESSION_PREFIX}-${sessionId}`);
43
-
59
+ const record = await CacheModel.findByPk(getSessionRecordId(sessionId));
60
+
44
61
  if (!record) return null;
45
-
62
+
63
+ if (isExpired(record)) {
64
+ if (record.status !== 'expired') {
65
+ await record.update({ status: 'expired' });
66
+ }
67
+ return {
68
+ sessionId: record.userId,
69
+ status: 'expired',
70
+ ...record.data
71
+ };
72
+ }
73
+
46
74
  return {
47
75
  sessionId: record.userId,
48
76
  status: record.status,
@@ -54,18 +82,22 @@ async function getAuthSession(sessionId) {
54
82
  * Update an auth session
55
83
  */
56
84
  async function updateAuthSession(sessionId, data) {
57
- const record = await CacheModel.findByPk(`${AUTH_SESSION_PREFIX}-${sessionId}`);
58
-
85
+ const record = await CacheModel.findByPk(getSessionRecordId(sessionId));
86
+
59
87
  if (!record) return;
60
-
88
+
61
89
  const existingData = record.data || {};
90
+ const nextStatus = data.status || record.status;
62
91
  await record.update({
63
- status: data.status || record.status,
92
+ status: nextStatus,
64
93
  data: {
65
94
  ...existingData,
66
95
  ...data,
67
96
  updatedAt: new Date().toISOString()
68
- }
97
+ },
98
+ expiry: nextStatus === 'pending'
99
+ ? getExpiry(PENDING_SESSION_EXPIRY_MINUTES)
100
+ : getExpiry(SETTLED_SESSION_EXPIRY_MINUTES)
69
101
  });
70
102
  }
71
103
 
package/mcp/mcpHandler.js CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  const axios = require('axios');
10
+ const { Op } = require('sequelize');
10
11
  const tools = require('./tools');
11
12
  const { LlmSessionModel } = require('../models/llmSessionModel');
12
13
  const { CacheModel } = require('../models/cacheModel');
@@ -23,6 +24,8 @@ const path = require('path');
23
24
  */
24
25
  const WIDGET_VERSION = 10;
25
26
  const WIDGET_URI = `ui://widget/ConnectorList-v${WIDGET_VERSION}.html`;
27
+ const RC_EXTENSION_CACHE_KEY = 'rcExtensionId';
28
+ const RC_EXTENSION_CACHE_STATUS = 'resolved';
26
29
 
27
30
  const JSON_RPC_INTERNAL_ERROR = -32603;
28
31
  const JSON_RPC_METHOD_NOT_FOUND = -32601;
@@ -104,7 +107,7 @@ async function resolveSessionContext(rcAccessToken, openaiSessionId) {
104
107
 
105
108
  if (openaiSessionId) {
106
109
  try {
107
- const cached = await CacheModel.findByPk(`${openaiSessionId}-rcExtensionId`);
110
+ const cached = await CacheModel.findByPk(`${openaiSessionId}-${RC_EXTENSION_CACHE_KEY}`);
108
111
  if (cached?.data?.rcExtensionId && (!cached.expiry || cached.expiry > new Date())) {
109
112
  return { rcExtensionId: cached.data.rcExtensionId };
110
113
  }
@@ -118,11 +121,11 @@ async function resolveSessionContext(rcAccessToken, openaiSessionId) {
118
121
  rcExtensionId = await resolveRcExtensionId(rcAccessToken);
119
122
  if (openaiSessionId && rcExtensionId) {
120
123
  await CacheModel.upsert({
121
- id: `${openaiSessionId}-rcExtensionId`,
124
+ id: `${openaiSessionId}-${RC_EXTENSION_CACHE_KEY}`,
122
125
  userId: openaiSessionId,
123
- cacheKey: 'rcExtensionId',
126
+ cacheKey: RC_EXTENSION_CACHE_KEY,
124
127
  data: { rcExtensionId },
125
- status: 'active',
128
+ status: RC_EXTENSION_CACHE_STATUS,
126
129
  expiry: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24h TTL
127
130
  });
128
131
  }
@@ -203,15 +206,34 @@ async function handleMcpRequest(req, res) {
203
206
  toolArgs.rcExtensionId = rcExtensionId;
204
207
  if (!toolArgs.jwtToken) {
205
208
  let llmSession = await LlmSessionModel.findByPk(rcExtensionId);
209
+ if (llmSession?.expiry && llmSession.expiry < new Date()) {
210
+ await LlmSessionModel.destroy({ where: { id: rcExtensionId } });
211
+ llmSession = null;
212
+ }
206
213
  if (!llmSession?.jwtToken && openaiSessionId) {
207
214
  const fallback = await LlmSessionModel.findByPk(openaiSessionId);
208
215
  if (fallback?.jwtToken) {
209
- await LlmSessionModel.upsert({ id: rcExtensionId, jwtToken: fallback.jwtToken });
210
- llmSession = fallback;
216
+ const { id: fallbackUserId } = jwt.decodeJwt(fallback.jwtToken);
217
+ const fallbackUser = fallbackUserId
218
+ ? await UserModel.findByPk(fallbackUserId)
219
+ : null;
220
+ if (fallbackUser?.accessToken) {
221
+ await LlmSessionModel.upsert({ id: rcExtensionId, jwtToken: fallback.jwtToken, expiry: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) });
222
+ llmSession = fallback;
223
+ }
211
224
  }
212
- else {
225
+ if (!llmSession?.jwtToken) {
213
226
  const hashedRcExtensionId = getHashValue(rcExtensionId, process.env.HASH_KEY);
214
- const user = await UserModel.findOne({ where: { hashedRcExtensionId } });
227
+ const user = await UserModel.findOne({
228
+ where: {
229
+ hashedRcExtensionId,
230
+ [Op.and]: [
231
+ { accessToken: { [Op.not]: null } },
232
+ { accessToken: { [Op.ne]: '' } },
233
+ ],
234
+ },
235
+ order: [['updatedAt', 'DESC']],
236
+ });
215
237
  if (user?.accessToken) {
216
238
  await LlmSessionModel.upsert({
217
239
  id: rcExtensionId,
@@ -219,6 +241,7 @@ async function handleMcpRequest(req, res) {
219
241
  id: user.id.toString(),
220
242
  platform: user.platform
221
243
  }),
244
+ expiry: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
222
245
  });
223
246
  llmSession = await LlmSessionModel.findByPk(rcExtensionId);
224
247
  }
@@ -3,7 +3,7 @@ const { LlmSessionModel } = require('../../models/llmSessionModel');
3
3
 
4
4
  /**
5
5
  * MCP Tool: Check Auth Status
6
- *
6
+ *
7
7
  * Polls the status of an ongoing OAuth authentication session
8
8
  */
9
9
 
@@ -37,7 +37,7 @@ const toolDefinition = {
37
37
  async function execute(args) {
38
38
  try {
39
39
  // rcExtensionId is injected by mcpHandler after verifying the RC access
40
- // token. Using it as the DB key binds the CRM credential to a verified
40
+ // token. Using it as the DB key binds the CRM credential to a verified
41
41
  // RC identity.
42
42
  const { sessionId, rcExtensionId } = args;
43
43
  if (!rcExtensionId) {
@@ -53,16 +53,12 @@ async function execute(args) {
53
53
  }
54
54
 
55
55
  switch (session.status) {
56
- case 'completed': {
57
- // Guard against duplicate DB writes if polled concurrently
58
- try {
59
- await LlmSessionModel.create({
60
- id: rcExtensionId,
61
- jwtToken: session.jwtToken
62
- });
63
- } catch {
64
- // Record already exists from a prior poll — safe to ignore
65
- }
56
+ case 'completed':
57
+ await LlmSessionModel.upsert({
58
+ id: rcExtensionId,
59
+ jwtToken: session.jwtToken,
60
+ expiry: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
61
+ });
66
62
  return {
67
63
  data: {
68
64
  status: 'completed',
@@ -71,7 +67,14 @@ async function execute(args) {
71
67
  message: 'IMPORTANT: Authentication successful! Keep jwtToken in memory for future use. DO NOT directly show it to user.'
72
68
  }
73
69
  };
74
- }
70
+
71
+ case 'expired':
72
+ return {
73
+ data: {
74
+ status: 'expired',
75
+ errorMessage: 'Authentication session expired. Ask the user to start the auth flow again.'
76
+ }
77
+ };
75
78
 
76
79
  case 'failed':
77
80
  return {
@@ -100,4 +103,3 @@ async function execute(args) {
100
103
 
101
104
  exports.definition = toolDefinition;
102
105
  exports.execute = execute;
103
-
@@ -1,4 +1,5 @@
1
1
  const axios = require('axios');
2
+ const { Op } = require('sequelize');
2
3
  const { UserModel } = require('../../models/userModel');
3
4
  const { getHashValue } = require('../../lib/util');
4
5
 
@@ -56,7 +57,16 @@ async function execute({ rcAccessToken, openaiSessionId } = {}) {
56
57
 
57
58
  // Check if user session already exists from Chrome extension
58
59
  const hashedRcExtensionId = getHashValue(rcExtensionId, process.env.HASH_KEY);
59
- const user = await UserModel.findOne({ where: { hashedRcExtensionId } });
60
+ const user = await UserModel.findOne({
61
+ where: {
62
+ hashedRcExtensionId,
63
+ [Op.and]: [
64
+ { accessToken: { [Op.not]: null } },
65
+ { accessToken: { [Op.ne]: '' } },
66
+ ],
67
+ },
68
+ order: [['updatedAt', 'DESC']],
69
+ });
60
70
  // Case: user exists, return user info in plain message
61
71
  if (user?.accessToken) {
62
72
  return {
@@ -0,0 +1,90 @@
1
+ const jwt = require('../../lib/jwt');
2
+ const { UserModel } = require('../../models/userModel');
3
+ const { RingCentral } = require('../../lib/ringcentral');
4
+
5
+ /**
6
+ * MCP Tool: Get Session Info
7
+ *
8
+ * Returns non-sensitive information about the current MCP/CRM session.
9
+ */
10
+
11
+ const toolDefinition = {
12
+ name: 'getSessionInfo',
13
+ description: 'Get the current user session info, including RingCentral identity and CRM connection status.',
14
+ inputSchema: {
15
+ type: 'object',
16
+ properties: {},
17
+ required: []
18
+ },
19
+ annotations: {
20
+ readOnlyHint: true,
21
+ openWorldHint: false,
22
+ destructiveHint: false
23
+ }
24
+ };
25
+
26
+ /**
27
+ * Execute the getSessionInfo tool
28
+ * @param {Object} args - Tool arguments injected by mcpHandler
29
+ * @param {string} [args.openaiSessionId] - OpenAI session identifier
30
+ * @param {string} [args.rcExtensionId] - Verified RingCentral extension identifier
31
+ * @param {string} [args.jwtToken] - CRM JWT token
32
+ * @param {string} [args.rcAccessToken] - RingCentral access token
33
+ * @returns {Object} Result object with session information
34
+ */
35
+ async function execute(args = {}) {
36
+ try {
37
+ const {
38
+ openaiSessionId = null,
39
+ rcExtensionId = null,
40
+ jwtToken,
41
+ rcAccessToken,
42
+ } = args;
43
+
44
+ const decodedToken = jwtToken ? jwt.decodeJwt(jwtToken) : null;
45
+ const userId = decodedToken?.id ?? null;
46
+ const user = userId ? await UserModel.findByPk(userId) : null;
47
+
48
+ let rcExtensionInfo = null;
49
+ if (rcExtensionId && rcAccessToken) {
50
+ const rcSDK = new RingCentral({
51
+ server: process.env.RINGCENTRAL_SERVER,
52
+ clientId: process.env.RINGCENTRAL_CLIENT_ID,
53
+ clientSecret: process.env.RINGCENTRAL_CLIENT_SECRET,
54
+ redirectUri: `${process.env.APP_SERVER}/ringcentral/oauth/callback`
55
+ });
56
+ rcExtensionInfo = await rcSDK.getExtensionInfo(rcExtensionId, {
57
+ access_token: rcAccessToken,
58
+ token_type: 'Bearer'
59
+ });
60
+ }
61
+ return {
62
+ success: true,
63
+ data: {
64
+ openaiSessionId,
65
+ dataToShow: {
66
+ isCrmAuthenticated: Boolean(decodedToken && user?.accessToken),
67
+ ringcentral: {
68
+ extensionId: rcExtensionId ?? null,
69
+ name: rcExtensionInfo?.name ?? null,
70
+ },
71
+ crm: {
72
+ userId,
73
+ platform: decodedToken?.platform ?? user?.platform ?? null,
74
+ hostname: user?.hostname ?? null
75
+ }
76
+ }
77
+ }
78
+ };
79
+ }
80
+ catch (error) {
81
+ return {
82
+ success: false,
83
+ error: error.message || 'Unknown error occurred',
84
+ errorDetails: error.stack
85
+ };
86
+ }
87
+ }
88
+
89
+ exports.definition = toolDefinition;
90
+ exports.execute = execute;
@@ -8,6 +8,7 @@
8
8
 
9
9
  const getHelp = require('./getHelp');
10
10
  const getPublicConnectors = require('./getPublicConnectors');
11
+ const getSessionInfo = require('./getSessionInfo');
11
12
  const doAuth = require('./doAuth');
12
13
  const checkAuthStatus = require('./checkAuthStatus');
13
14
  const logout = require('./logout');
@@ -15,13 +16,14 @@ const findContact = require('./findContactByPhone');
15
16
  const findContactWithName = require('./findContactByName');
16
17
  const createCallLog = require('./createCallLog');
17
18
  const rcGetCallLogs = require('./rcGetCallLogs');
18
- const getGoogleFilePicker = require('./getGoogleFilePicker');
19
+ // const getGoogleFilePicker = require('./getGoogleFilePicker');
19
20
  const createContact = require('./createContact');
20
21
 
21
22
  // AI-visible MCP tools — registered in the MCP server
22
23
  module.exports.tools = [
23
24
  getHelp,
24
25
  getPublicConnectors,
26
+ getSessionInfo,
25
27
  logout,
26
28
  findContact,
27
29
  findContactWithName,
@@ -1,8 +1,11 @@
1
1
  const jwt = require('../../lib/jwt');
2
2
  const { UserModel } = require('../../models/userModel');
3
3
  const { LlmSessionModel } = require('../../models/llmSessionModel');
4
+ const { CacheModel } = require('../../models/cacheModel');
4
5
  const connectorRegistry = require('../../connector/registry');
5
6
 
7
+ const RC_EXTENSION_CACHE_KEY = 'rcExtensionId';
8
+
6
9
  /**
7
10
  * MCP Tool: Logout
8
11
  *
@@ -38,7 +41,7 @@ function isMissingSessionTableError(error) {
38
41
  */
39
42
  async function execute(args) {
40
43
  try {
41
- const { jwtToken } = args;
44
+ const { jwtToken, rcExtensionId, openaiSessionId } = args;
42
45
  const session = jwt.decodeJwt(jwtToken);
43
46
  if (!session?.platform || !session?.id) {
44
47
  throw new Error('Invalid JWT token');
@@ -46,6 +49,12 @@ async function execute(args) {
46
49
  const { platform, id } = session;
47
50
  try {
48
51
  await LlmSessionModel.destroy({ where: { id } });
52
+ if (rcExtensionId && rcExtensionId !== id) {
53
+ await LlmSessionModel.destroy({ where: { id: rcExtensionId } });
54
+ }
55
+ if (openaiSessionId) {
56
+ await CacheModel.destroy({ where: { id: `${openaiSessionId}-${RC_EXTENSION_CACHE_KEY}` } });
57
+ }
49
58
  }
50
59
  catch (error) {
51
60
  if (!isMissingSessionTableError(error)) {
@@ -10,5 +10,8 @@ exports.LlmSessionModel = sequelize.define('llmSessions', {
10
10
  },
11
11
  jwtToken: {
12
12
  type: Sequelize.STRING,
13
+ },
14
+ expiry: {
15
+ type: Sequelize.DATE
13
16
  }
14
17
  });
package/package.json CHANGED
@@ -1,72 +1,72 @@
1
- {
2
- "name": "@app-connect/core",
3
- "version": "1.7.23",
4
- "description": "RingCentral App Connect Core",
5
- "main": "index.js",
6
- "repository": {
7
- "type": "git",
8
- "url": "git+https://github.com/ringcentral/rc-unified-crm-extension.git"
9
- },
10
- "keywords": [
11
- "RingCentral",
12
- "App Connect"
13
- ],
14
- "author": "RingCentral Labs",
15
- "license": "MIT",
16
- "peerDependencies": {
17
- "axios": "^1.12.2",
18
- "express": "^4.22.1",
19
- "moment": "^2.29.4",
20
- "moment-timezone": "^0.5.39",
21
- "pg": "^8.8.0",
22
- "sequelize": "^6.29.0"
23
- },
24
- "dependencies": {
25
- "@aws-sdk/client-dynamodb": "^3.751.0",
26
- "@aws-sdk/client-s3": "^3.947.0",
27
- "@aws-sdk/s3-request-presigner": "^3.947.0",
28
- "@modelcontextprotocol/sdk": "^1.26.0",
29
- "awesome-phonenumber": "^5.6.0",
30
- "body-parser": "^1.20.4",
31
- "body-parser-xml": "^2.0.5",
32
- "client-oauth2": "^4.3.3",
33
- "cors": "^2.8.5",
34
- "country-state-city": "^3.2.1",
35
- "dotenv": "^16.0.3",
36
- "dynamoose": "^4.0.3",
37
- "jsonwebtoken": "^9.0.0",
38
- "mixpanel": "^0.18.0",
39
- "shortid": "^2.2.17",
40
- "tz-lookup": "^6.1.25",
41
- "ua-parser-js": "^1.0.38"
42
- },
43
- "scripts": {
44
- "test": "jest",
45
- "test:watch": "jest --watch",
46
- "test:coverage": "jest --coverage",
47
- "test:ci": "jest --ci --coverage --watchAll=false"
48
- },
49
- "devDependencies": {
50
- "@eslint/js": "^9.22.0",
51
- "@octokit/rest": "^19.0.5",
52
- "axios": "^1.12.2",
53
- "eslint": "^9.22.0",
54
- "express": "^4.22.1",
55
- "globals": "^16.0.0",
56
- "jest": "^29.3.1",
57
- "moment": "^2.29.4",
58
- "moment-timezone": "^0.5.39",
59
- "nock": "^13.2.9",
60
- "pg": "^8.8.0",
61
- "sequelize": "^6.29.0",
62
- "sqlite3": "^5.1.2",
63
- "supertest": "^6.3.1"
64
- },
65
- "overrides": {
66
- "js-object-utilities": "2.2.1"
67
- },
68
- "bugs": {
69
- "url": "https://github.com/ringcentral/rc-unified-crm-extension/issues"
70
- },
71
- "homepage": "https://github.com/ringcentral/rc-unified-crm-extension#readme"
72
- }
1
+ {
2
+ "name": "@app-connect/core",
3
+ "version": "1.7.25",
4
+ "description": "RingCentral App Connect Core",
5
+ "main": "index.js",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/ringcentral/rc-unified-crm-extension.git"
9
+ },
10
+ "keywords": [
11
+ "RingCentral",
12
+ "App Connect"
13
+ ],
14
+ "author": "RingCentral Labs",
15
+ "license": "MIT",
16
+ "peerDependencies": {
17
+ "axios": "^1.12.2",
18
+ "express": "^4.22.1",
19
+ "moment": "^2.29.4",
20
+ "moment-timezone": "^0.5.39",
21
+ "pg": "^8.8.0",
22
+ "sequelize": "^6.29.0"
23
+ },
24
+ "dependencies": {
25
+ "@aws-sdk/client-dynamodb": "^3.751.0",
26
+ "@aws-sdk/client-s3": "^3.947.0",
27
+ "@aws-sdk/s3-request-presigner": "^3.947.0",
28
+ "@modelcontextprotocol/sdk": "^1.26.0",
29
+ "awesome-phonenumber": "^5.6.0",
30
+ "body-parser": "^1.20.4",
31
+ "body-parser-xml": "^2.0.5",
32
+ "client-oauth2": "^4.3.3",
33
+ "cors": "^2.8.5",
34
+ "country-state-city": "^3.2.1",
35
+ "dotenv": "^16.0.3",
36
+ "dynamoose": "^4.0.3",
37
+ "jsonwebtoken": "^9.0.0",
38
+ "mixpanel": "^0.18.0",
39
+ "shortid": "^2.2.17",
40
+ "tz-lookup": "^6.1.25",
41
+ "ua-parser-js": "^1.0.38"
42
+ },
43
+ "scripts": {
44
+ "test": "jest",
45
+ "test:watch": "jest --watch",
46
+ "test:coverage": "jest --coverage",
47
+ "test:ci": "jest --ci --coverage --watchAll=false"
48
+ },
49
+ "devDependencies": {
50
+ "@eslint/js": "^9.22.0",
51
+ "@octokit/rest": "^19.0.5",
52
+ "axios": "^1.12.2",
53
+ "eslint": "^9.22.0",
54
+ "express": "^4.22.1",
55
+ "globals": "^16.0.0",
56
+ "jest": "^29.3.1",
57
+ "moment": "^2.29.4",
58
+ "moment-timezone": "^0.5.39",
59
+ "nock": "^13.2.9",
60
+ "pg": "^8.8.0",
61
+ "sequelize": "^6.29.0",
62
+ "sqlite3": "^5.1.2",
63
+ "supertest": "^6.3.1"
64
+ },
65
+ "overrides": {
66
+ "js-object-utilities": "2.2.1"
67
+ },
68
+ "bugs": {
69
+ "url": "https://github.com/ringcentral/rc-unified-crm-extension/issues"
70
+ },
71
+ "homepage": "https://github.com/ringcentral/rc-unified-crm-extension#readme"
72
+ }
package/releaseNotes.json CHANGED
@@ -1,4 +1,24 @@
1
1
  {
2
+ "1.7.25": {
3
+ "global": [
4
+ {
5
+ "type": "Fix",
6
+ "description": "Click-to-dial render issue"
7
+ }
8
+ ]
9
+ },
10
+ "1.7.24": {
11
+ "global": [
12
+ {
13
+ "type": "New",
14
+ "description": "Click-to-dial number matcher selection in User Setting -> General"
15
+ },
16
+ {
17
+ "type": "Fix",
18
+ "description": "Error report upload"
19
+ }
20
+ ]
21
+ },
2
22
  "1.7.23": {
3
23
  "global": [
4
24
  {
@@ -88,6 +88,39 @@ describe('proxy engine utilities', () => {
88
88
  // Basic base64('token-123')
89
89
  expect(args.headers.Authorization).toMatch(/^Basic /);
90
90
  });
91
+
92
+ test('performRequest uses submitted apiKey during first login before user is saved', async () => {
93
+ axios.mockResolvedValue({ data: { ok: true } });
94
+ const config = {
95
+ auth: {
96
+ type: 'apiKey',
97
+ scheme: 'Basic',
98
+ credentialTemplate: '{{apiKey}}',
99
+ encode: 'base64',
100
+ headerName: 'Authorization'
101
+ },
102
+ requestDefaults: {
103
+ baseUrl: 'https://api.example.com'
104
+ },
105
+ operations: {
106
+ getUserInfo: {
107
+ method: 'GET',
108
+ url: '/authentication'
109
+ }
110
+ }
111
+ };
112
+
113
+ await performRequest({
114
+ config,
115
+ opName: 'getUserInfo',
116
+ inputs: { apiKey: 'login-key' },
117
+ user: {},
118
+ authHeader: undefined
119
+ });
120
+
121
+ const args = axios.mock.calls[0][0];
122
+ expect(args.headers.Authorization).toBe(`Basic ${Buffer.from('login-key').toString('base64')}`);
123
+ });
91
124
  });
92
125
 
93
126
 
@@ -24,6 +24,7 @@ const { RingCentral } = require('../../lib/ringcentral');
24
24
  const adminCore = require('../../handlers/admin');
25
25
  const { AccountDataModel } = require('../../models/accountDataModel');
26
26
  const { encode } = require('../../lib/encode');
27
+ const { getHashValue } = require('../../lib/util');
27
28
 
28
29
  describe('Auth Handler', () => {
29
30
  const originalEnv = process.env;
@@ -964,6 +965,8 @@ describe('Auth Handler', () => {
964
965
 
965
966
  test('should handle successful RingCentral OAuth callback', async () => {
966
967
  // Arrange
968
+ process.env.HASH_KEY = 'test-hash-key';
969
+ const rcAccountId = 'rc-account-id';
967
970
  const mockGenerateToken = jest.fn().mockResolvedValue({
968
971
  access_token: 'rc-access-token',
969
972
  refresh_token: 'rc-refresh-token',
@@ -977,7 +980,7 @@ describe('Auth Handler', () => {
977
980
  // Act
978
981
  await authHandler.onRingcentralOAuthCallback({
979
982
  code: 'rc-auth-code',
980
- rcAccountId: 'hashed-rc-account-id'
983
+ rcAccountId
981
984
  });
982
985
 
983
986
  // Assert
@@ -989,7 +992,7 @@ describe('Auth Handler', () => {
989
992
  });
990
993
  expect(mockGenerateToken).toHaveBeenCalledWith({ code: 'rc-auth-code' });
991
994
  expect(adminCore.updateAdminRcTokens).toHaveBeenCalledWith({
992
- hashedRcAccountId: 'hashed-rc-account-id',
995
+ hashedRcAccountId: getHashValue(rcAccountId, 'test-hash-key'),
993
996
  adminAccessToken: 'rc-access-token',
994
997
  adminRefreshToken: 'rc-refresh-token',
995
998
  adminTokenExpiry: expect.any(Number)
@@ -180,7 +180,7 @@ describe('Managed Auth Handler', () => {
180
180
  rcAccountId: 'acc-3'
181
181
  });
182
182
 
183
- expect(developerPortal.getConnectorManifest).toHaveBeenCalledWith({ connectorId: 'connector-123', isPrivate: false });
183
+ expect(developerPortal.getConnectorManifest).toHaveBeenCalledWith({ rcAccountId: 'acc-3', connectorId: 'connector-123', isPrivate: false });
184
184
  expect(state.hasManagedAuth).toBe(true);
185
185
  expect(state.allRequiredFieldsSatisfied).toBe(true);
186
186
  expect(state.visibleFieldConsts).toEqual([]);
@@ -0,0 +1,83 @@
1
+ const checkAuthStatus = require('../../../mcp/tools/checkAuthStatus');
2
+ const { getAuthSession } = require('../../../lib/authSession');
3
+ const { LlmSessionModel } = require('../../../models/llmSessionModel');
4
+
5
+ jest.mock('../../../lib/authSession');
6
+ jest.mock('../../../models/llmSessionModel');
7
+
8
+ describe('MCP Tool: checkAuthStatus', () => {
9
+ beforeEach(() => {
10
+ jest.clearAllMocks();
11
+ LlmSessionModel.upsert.mockResolvedValue([{}, true]);
12
+ });
13
+
14
+ test('should return pending when auth session is still waiting', async () => {
15
+ getAuthSession.mockResolvedValue({ status: 'pending' });
16
+
17
+ const result = await checkAuthStatus.execute({
18
+ sessionId: 'session-1',
19
+ rcExtensionId: 'rc-ext-1'
20
+ });
21
+
22
+ expect(result).toEqual({
23
+ data: {
24
+ status: 'pending'
25
+ }
26
+ });
27
+ expect(LlmSessionModel.upsert).not.toHaveBeenCalled();
28
+ });
29
+
30
+ test('should persist completed auth against rcExtensionId', async () => {
31
+ getAuthSession.mockResolvedValue({
32
+ status: 'completed',
33
+ jwtToken: 'jwt-token',
34
+ userInfo: { id: 'user-1', name: 'Casey' }
35
+ });
36
+
37
+ const result = await checkAuthStatus.execute({
38
+ sessionId: 'session-1',
39
+ rcExtensionId: 'rc-ext-1'
40
+ });
41
+
42
+ expect(LlmSessionModel.upsert).toHaveBeenCalledWith({
43
+ id: 'rc-ext-1',
44
+ jwtToken: 'jwt-token',
45
+ expiry: expect.any(Date)
46
+ });
47
+ expect(result).toEqual({
48
+ data: {
49
+ status: 'completed',
50
+ jwtToken: 'jwt-token',
51
+ userInfo: { id: 'user-1', name: 'Casey' },
52
+ message: expect.stringContaining('IMPORTANT')
53
+ }
54
+ });
55
+ });
56
+
57
+ test('should return expired when the auth session TTL has elapsed', async () => {
58
+ getAuthSession.mockResolvedValue({ status: 'expired' });
59
+
60
+ const result = await checkAuthStatus.execute({
61
+ sessionId: 'session-1',
62
+ rcExtensionId: 'rc-ext-1'
63
+ });
64
+
65
+ expect(result).toEqual({
66
+ data: {
67
+ status: 'expired',
68
+ errorMessage: 'Authentication session expired. Ask the user to start the auth flow again.'
69
+ }
70
+ });
71
+ });
72
+
73
+ test('should return an error when rcExtensionId is missing', async () => {
74
+ const result = await checkAuthStatus.execute({
75
+ sessionId: 'session-1'
76
+ });
77
+
78
+ expect(result).toEqual({
79
+ success: false,
80
+ error: 'CRM auth status check error: rcExtensionId is required'
81
+ });
82
+ });
83
+ });
@@ -0,0 +1,127 @@
1
+ const getSessionInfo = require('../../../mcp/tools/getSessionInfo');
2
+ const jwt = require('../../../lib/jwt');
3
+ const { UserModel } = require('../../../models/userModel');
4
+ const { RingCentral } = require('../../../lib/ringcentral');
5
+
6
+ jest.mock('../../../lib/jwt');
7
+ jest.mock('../../../models/userModel');
8
+ jest.mock('../../../lib/ringcentral');
9
+
10
+ describe('MCP Tool: getSessionInfo', () => {
11
+ beforeEach(() => {
12
+ jest.clearAllMocks();
13
+ RingCentral.mockImplementation(() => ({
14
+ getExtensionInfo: jest.fn().mockResolvedValue({ name: 'Demo Extension' })
15
+ }));
16
+ });
17
+
18
+ describe('tool definition', () => {
19
+ test('should have correct tool definition', () => {
20
+ expect(getSessionInfo.definition).toBeDefined();
21
+ expect(getSessionInfo.definition.name).toBe('getSessionInfo');
22
+ expect(getSessionInfo.definition.description).toContain('session info');
23
+ expect(getSessionInfo.definition.inputSchema).toBeDefined();
24
+ expect(getSessionInfo.definition.inputSchema.properties).toEqual({});
25
+ });
26
+ });
27
+
28
+ describe('execute', () => {
29
+ test('should return unauthenticated session info when no CRM jwtToken exists', async () => {
30
+ const result = await getSessionInfo.execute({
31
+ openaiSessionId: 'session-123',
32
+ rcExtensionId: 'ext-456',
33
+ rcAccessToken: 'rc-token'
34
+ });
35
+
36
+ expect(result).toEqual({
37
+ success: true,
38
+ data: {
39
+ openaiSessionId: 'session-123',
40
+ dataToShow: {
41
+ isCrmAuthenticated: false,
42
+ ringcentral: {
43
+ extensionId: 'ext-456',
44
+ name: 'Demo Extension',
45
+ },
46
+ crm: {
47
+ userId: null,
48
+ platform: null,
49
+ hostname: null
50
+ }
51
+ }
52
+ }
53
+ });
54
+ expect(jwt.decodeJwt).not.toHaveBeenCalled();
55
+ expect(UserModel.findByPk).not.toHaveBeenCalled();
56
+ });
57
+
58
+ test('should return connected CRM session info when jwtToken resolves to a saved user', async () => {
59
+ jwt.decodeJwt.mockReturnValue({
60
+ id: 'crm-user-1',
61
+ platform: 'clio'
62
+ });
63
+ UserModel.findByPk.mockResolvedValue({
64
+ id: 'crm-user-1',
65
+ platform: 'clio',
66
+ hostname: 'app.clio.com',
67
+ accessToken: 'crm-access-token',
68
+ });
69
+
70
+ const result = await getSessionInfo.execute({
71
+ openaiSessionId: 'session-123',
72
+ rcExtensionId: 'ext-456',
73
+ rcAccessToken: 'rc-token',
74
+ jwtToken: 'jwt-token'
75
+ });
76
+
77
+ expect(jwt.decodeJwt).toHaveBeenCalledWith('jwt-token');
78
+ expect(UserModel.findByPk).toHaveBeenCalledWith('crm-user-1');
79
+ expect(result).toEqual({
80
+ success: true,
81
+ data: {
82
+ openaiSessionId: 'session-123',
83
+ dataToShow: {
84
+ isCrmAuthenticated: true,
85
+ ringcentral: {
86
+ extensionId: 'ext-456',
87
+ name: 'Demo Extension',
88
+ },
89
+ crm: {
90
+ userId: 'crm-user-1',
91
+ platform: 'clio',
92
+ hostname: 'app.clio.com'
93
+ }
94
+ }
95
+ }
96
+ });
97
+ });
98
+
99
+ test('should report not authenticated when jwtToken is invalid', async () => {
100
+ jwt.decodeJwt.mockReturnValue(null);
101
+
102
+ const result = await getSessionInfo.execute({
103
+ jwtToken: 'bad-token'
104
+ });
105
+
106
+ expect(result).toEqual({
107
+ success: true,
108
+ data: {
109
+ openaiSessionId: null,
110
+ dataToShow: {
111
+ isCrmAuthenticated: false,
112
+ ringcentral: {
113
+ extensionId: null,
114
+ name: null,
115
+ },
116
+ crm: {
117
+ userId: null,
118
+ platform: null,
119
+ hostname: null
120
+ }
121
+ }
122
+ }
123
+ });
124
+ expect(UserModel.findByPk).not.toHaveBeenCalled();
125
+ });
126
+ });
127
+ });
@@ -1,16 +1,22 @@
1
1
  const logout = require('../../../mcp/tools/logout');
2
2
  const jwt = require('../../../lib/jwt');
3
3
  const { UserModel } = require('../../../models/userModel');
4
+ const { LlmSessionModel } = require('../../../models/llmSessionModel');
5
+ const { CacheModel } = require('../../../models/cacheModel');
4
6
  const connectorRegistry = require('../../../connector/registry');
5
7
 
6
8
  // Mock dependencies
7
9
  jest.mock('../../../lib/jwt');
8
10
  jest.mock('../../../models/userModel');
11
+ jest.mock('../../../models/llmSessionModel');
12
+ jest.mock('../../../models/cacheModel');
9
13
  jest.mock('../../../connector/registry');
10
14
 
11
15
  describe('MCP Tool: logout', () => {
12
16
  beforeEach(() => {
13
17
  jest.clearAllMocks();
18
+ LlmSessionModel.destroy.mockResolvedValue(1);
19
+ CacheModel.destroy.mockResolvedValue(1);
14
20
  });
15
21
 
16
22
  describe('tool definition', () => {
@@ -65,6 +71,7 @@ describe('MCP Tool: logout', () => {
65
71
  }
66
72
  });
67
73
  expect(jwt.decodeJwt).toHaveBeenCalledWith('mock-jwt-token');
74
+ expect(LlmSessionModel.destroy).toHaveBeenCalledWith({ where: { id: 'test-user-id' } });
68
75
  expect(UserModel.findByPk).toHaveBeenCalledWith('test-user-id');
69
76
  expect(connectorRegistry.getConnector).toHaveBeenCalledWith('testCRM');
70
77
  expect(mockConnector.unAuthorize).toHaveBeenCalledWith({
@@ -170,6 +177,57 @@ describe('MCP Tool: logout', () => {
170
177
  expect(consoleSpy).toHaveBeenCalled();
171
178
  consoleSpy.mockRestore();
172
179
  });
180
+
181
+ test('should clear both CRM user and RC extension session rows when rcExtensionId is provided', async () => {
182
+ const mockUser = {
183
+ id: 'test-user-id',
184
+ platform: 'testCRM'
185
+ };
186
+
187
+ jwt.decodeJwt.mockReturnValue({
188
+ id: 'test-user-id',
189
+ platform: 'testCRM'
190
+ });
191
+ UserModel.findByPk.mockResolvedValue(mockUser);
192
+ connectorRegistry.getConnector.mockReturnValue({
193
+ unAuthorize: jest.fn().mockResolvedValue({})
194
+ });
195
+
196
+ const result = await logout.execute({
197
+ jwtToken: 'mock-jwt-token',
198
+ rcExtensionId: 'rc-ext-123'
199
+ });
200
+
201
+ expect(result.success).toBe(true);
202
+ expect(LlmSessionModel.destroy).toHaveBeenNthCalledWith(1, { where: { id: 'test-user-id' } });
203
+ expect(LlmSessionModel.destroy).toHaveBeenNthCalledWith(2, { where: { id: 'rc-ext-123' } });
204
+ });
205
+
206
+ test('should clear the resolved rcExtension cache for the current OpenAI session on logout', async () => {
207
+ const mockUser = {
208
+ id: 'test-user-id',
209
+ platform: 'testCRM'
210
+ };
211
+
212
+ jwt.decodeJwt.mockReturnValue({
213
+ id: 'test-user-id',
214
+ platform: 'testCRM'
215
+ });
216
+ UserModel.findByPk.mockResolvedValue(mockUser);
217
+ connectorRegistry.getConnector.mockReturnValue({
218
+ unAuthorize: jest.fn().mockResolvedValue({})
219
+ });
220
+
221
+ const result = await logout.execute({
222
+ jwtToken: 'mock-jwt-token',
223
+ openaiSessionId: 'oa-session-123'
224
+ });
225
+
226
+ expect(result.success).toBe(true);
227
+ expect(CacheModel.destroy).toHaveBeenCalledWith({
228
+ where: { id: 'oa-session-123-rcExtensionId' }
229
+ });
230
+ });
173
231
  });
174
232
  });
175
233
 
@@ -32,17 +32,6 @@ describe('Managed Auth Routes', () => {
32
32
  });
33
33
 
34
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
35
  test('should validate rcAccessToken and use validated identity', async () => {
47
36
  adminCore.validateRcUserToken.mockResolvedValue({
48
37
  rcAccountId: 'validated-account-id',
@@ -75,20 +64,6 @@ describe('Managed Auth Routes', () => {
75
64
  });
76
65
 
77
66
  describe('POST /apiKeyLogin', () => {
78
- test('should require rcAccessToken', async () => {
79
- const response = await request(app)
80
- .post('/apiKeyLogin')
81
- .send({
82
- platform: 'testCRM',
83
- apiKey: 'api-key',
84
- hostname: 'test.example.com',
85
- });
86
-
87
- expect(response.status).toBe(400);
88
- expect(response.text).toContain('Missing RingCentral access token');
89
- expect(adminCore.validateRcUserToken).not.toHaveBeenCalled();
90
- expect(authCore.onApiKeyLogin).not.toHaveBeenCalled();
91
- });
92
67
 
93
68
  test('should validate rcAccessToken and ignore spoofed rc ids in body', async () => {
94
69
  adminCore.validateRcUserToken.mockResolvedValue({