@app-connect/core 1.7.19 → 1.7.21

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.
package/handlers/auth.js CHANGED
@@ -7,7 +7,7 @@ const adminCore = require('./admin');
7
7
  const { Connector } = require('../models/dynamo/connectorSchema');
8
8
  const { handleDatabaseError } = require('../lib/errorHandler');
9
9
 
10
- async function onOAuthCallback({ platform, hostname, tokenUrl, query, isFromMCP = false }) {
10
+ async function onOAuthCallback({ platform, hostname, tokenUrl, query, hashedRcExtensionId, isFromMCP = false }) {
11
11
  const callbackUri = query.callbackUri;
12
12
  const apiUrl = query.apiUrl;
13
13
  const username = query.username;
@@ -54,6 +54,7 @@ async function onOAuthCallback({ platform, hostname, tokenUrl, query, isFromMCP
54
54
  refreshToken,
55
55
  tokenExpiry: isNaN(expires) ? null : expires,
56
56
  rcAccountId: query?.rcAccountId,
57
+ hashedRcExtensionId,
57
58
  proxyId
58
59
  });
59
60
  }
@@ -76,7 +77,7 @@ async function onOAuthCallback({ platform, hostname, tokenUrl, query, isFromMCP
76
77
  }
77
78
  }
78
79
 
79
- async function onApiKeyLogin({ platform, hostname, apiKey, proxyId, additionalInfo }) {
80
+ async function onApiKeyLogin({ platform, hostname, apiKey, proxyId, rcAccountId, hashedRcExtensionId, additionalInfo }) {
80
81
  const platformModule = connectorRegistry.getConnector(platform);
81
82
  const basicAuth = platformModule.getBasicAuth({ apiKey });
82
83
  const { successful, platformUserInfo, returnMessage } = await platformModule.getUserInfo({ authHeader: `Basic ${basicAuth}`, hostname, platform, additionalInfo, apiKey, proxyId });
@@ -88,6 +89,8 @@ async function onApiKeyLogin({ platform, hostname, apiKey, proxyId, additionalIn
88
89
  platform,
89
90
  hostname,
90
91
  proxyId,
92
+ hashedRcExtensionId,
93
+ rcAccountId,
91
94
  accessToken: platformUserInfo.overridingApiKey ?? apiKey
92
95
  });
93
96
  }
@@ -110,7 +113,7 @@ async function onApiKeyLogin({ platform, hostname, apiKey, proxyId, additionalIn
110
113
  }
111
114
  }
112
115
 
113
- async function saveUserInfo({ platformUserInfo, platform, hostname, accessToken, refreshToken, tokenExpiry, rcAccountId, proxyId }) {
116
+ async function saveUserInfo({ platformUserInfo, platform, hostname, accessToken, refreshToken, tokenExpiry, rcAccountId, hashedRcExtensionId, proxyId }) {
114
117
  const id = platformUserInfo.id;
115
118
  const name = platformUserInfo.name;
116
119
  const existingUser = await UserModel.findByPk(id);
@@ -130,6 +133,7 @@ async function saveUserInfo({ platformUserInfo, platform, hostname, accessToken,
130
133
  refreshToken,
131
134
  tokenExpiry,
132
135
  rcAccountId,
136
+ hashedRcExtensionId,
133
137
  platformAdditionalInfo: {
134
138
  ...existingUser.platformAdditionalInfo, // keep existing platformAdditionalInfo
135
139
  ...platformAdditionalInfo,
@@ -143,57 +147,20 @@ async function saveUserInfo({ platformUserInfo, platform, hostname, accessToken,
143
147
  }
144
148
  else {
145
149
  try {
146
- // TEMP: replace user with old ID
147
- if (id.endsWith(`-${platform}`)) {
148
- const oldID = id.split('-');
149
- const userWithOldID = await UserModel.findByPk(oldID[0]);
150
- if (userWithOldID) {
151
- await UserModel.create({
152
- id,
153
- hostname,
154
- timezoneName,
155
- timezoneOffset,
156
- platform,
157
- accessToken,
158
- refreshToken,
159
- tokenExpiry,
160
- rcAccountId,
161
- platformAdditionalInfo,
162
- userSettings: userWithOldID.userSettings
163
- });
164
- await userWithOldID.destroy();
165
- }
166
- else {
167
- await UserModel.create({
168
- id,
169
- hostname,
170
- timezoneName,
171
- timezoneOffset,
172
- platform,
173
- accessToken,
174
- refreshToken,
175
- tokenExpiry,
176
- rcAccountId,
177
- platformAdditionalInfo,
178
- userSettings: {}
179
- });
180
- }
181
- }
182
- else {
183
- await UserModel.create({
184
- id,
185
- hostname,
186
- timezoneName,
187
- timezoneOffset,
188
- platform,
189
- accessToken,
190
- refreshToken,
191
- tokenExpiry,
192
- rcAccountId,
193
- platformAdditionalInfo,
194
- userSettings: {}
195
- });
196
- }
150
+ await UserModel.create({
151
+ id,
152
+ hostname,
153
+ timezoneName,
154
+ timezoneOffset,
155
+ platform,
156
+ accessToken,
157
+ refreshToken,
158
+ tokenExpiry,
159
+ rcAccountId,
160
+ hashedRcExtensionId,
161
+ platformAdditionalInfo,
162
+ userSettings: {}
163
+ });
197
164
  }
198
165
  catch (error) {
199
166
  return handleDatabaseError(error, 'Error saving user info');
@@ -206,8 +173,16 @@ async function saveUserInfo({ platformUserInfo, platform, hostname, accessToken,
206
173
  }
207
174
 
208
175
  async function getLicenseStatus({ userId, platform }) {
176
+ const user = await UserModel.findByPk(userId);
177
+ if (!user) {
178
+ return {
179
+ isLicenseValid: false,
180
+ licenseStatus: 'Invalid (User not found)',
181
+ licenseStatusDescription: ''
182
+ }
183
+ }
209
184
  const platformModule = connectorRegistry.getConnector(platform);
210
- const licenseStatus = await platformModule.getLicenseStatus({ userId, platform });
185
+ const licenseStatus = await platformModule.getLicenseStatus({ userId, platform, user });
211
186
  return licenseStatus;
212
187
  }
213
188
 
@@ -80,7 +80,8 @@ async function findContact({ platform, userId, phoneNumber, overridingFormat, is
80
80
  const { successful, matchedContactInfo, returnMessage, extraDataTracking } = await platformModule.findContact({ user, authHeader, phoneNumber, overridingFormat, isExtension, proxyConfig, tracer, isForceRefreshAccountData });
81
81
  tracer?.trace('handler.findContact:platformFindResult', { successful, matchedContactInfo });
82
82
 
83
- if (matchedContactInfo != null && matchedContactInfo?.filter(c => !c.isNewContact)?.length > 0) {
83
+ const matchedNonNewContacts = matchedContactInfo?.filter(c => !c.isNewContact) ?? [];
84
+ if (matchedContactInfo != null && matchedNonNewContacts.length > 0) {
84
85
  tracer?.trace('handler.findContact:contactsFound', { count: matchedContactInfo.length });
85
86
  // save in org data
86
87
  // Danger: it does NOT support one RC account mapping to multiple CRM platforms, because contacts will be shared
@@ -104,6 +105,10 @@ async function findContact({ platform, userId, phoneNumber, overridingFormat, is
104
105
  }
105
106
  else {
106
107
  tracer?.trace('handler.findContact:noContactsMatched', { matchedContactInfo });
108
+ if (isForceRefreshAccountData && existingMatchedContactInfo) {
109
+ await existingMatchedContactInfo.destroy();
110
+ tracer?.trace('handler.findContact:staleCacheRemoved', { phoneNumber });
111
+ }
107
112
  if (returnMessage) {
108
113
  return {
109
114
  successful,
@@ -272,4 +277,4 @@ async function findContactWithName({ platform, userId, name }) {
272
277
 
273
278
  exports.findContact = findContact;
274
279
  exports.createContact = createContact;
275
- exports.findContactWithName = findContactWithName;
280
+ exports.findContactWithName = findContactWithName;
package/handlers/log.js CHANGED
@@ -129,11 +129,11 @@ async function createCallLog({ jwtToken, platform, userId, incomingData, hashedA
129
129
  let pluginDataResponse = null;
130
130
  switch (pluginSetting.value.access) {
131
131
  case 'public':
132
- pluginDataResponse = await axios.get(`${process.env.DEV_PORTAL_URL}/public-api/connectors/${pluginId}/manifest?type=plugin`);
132
+ pluginDataResponse = await axios.get(`https://appconnect.labs.ringcentral.com/public-api/connectors/${pluginId}/manifest?type=plugin`);
133
133
  break;
134
134
  case 'private':
135
135
  case 'shared':
136
- pluginDataResponse = await axios.get(`${process.env.DEV_PORTAL_URL}/public-api/connectors/${pluginId}/manifest?access=internal&type=connector&accountId=${user.rcAccountId}`);
136
+ pluginDataResponse = await axios.get(`https://appconnect.labs.ringcentral.com/public-api/connectors/${pluginId}/manifest?access=internal&type=connector&accountId=${user.rcAccountId}`);
137
137
  break;
138
138
  default:
139
139
  throw new Error('Invalid plugin access');
@@ -174,6 +174,7 @@ async function createCallLog({ jwtToken, platform, userId, incomingData, hashedA
174
174
  });
175
175
  // eslint-disable-next-line no-param-reassign
176
176
  incomingData = processedResultResponse.data;
177
+ note = incomingData.note;
177
178
  }
178
179
  }
179
180
 
package/index.js CHANGED
@@ -3,6 +3,7 @@ const cors = require('cors')
3
3
  const bodyParser = require('body-parser');
4
4
  require('body-parser-xml')(bodyParser);
5
5
  const dynamoose = require('dynamoose');
6
+ const Sequelize = require('sequelize');
6
7
  const { DynamoDB } = require('@aws-sdk/client-dynamodb');
7
8
  const axios = require('axios');
8
9
  const { UserModel } = require('./models/userModel');
@@ -40,7 +41,7 @@ try {
40
41
  packageJson = require('./package.json');
41
42
  }
42
43
  catch (e) {
43
- logger.error('Error loading package.json', { stack: e.stack });
44
+ logger.error('Error loading package.json', { stack: e.stack });
44
45
  packageJson = require('../package.json');
45
46
  }
46
47
 
@@ -73,6 +74,20 @@ async function initDB() {
73
74
  await CacheModel.sync();
74
75
  await CallDownListModel.sync();
75
76
  await AccountDataModel.sync();
77
+
78
+ // if UserModel doesn't have hashedRcExtensionId column, add it
79
+ const queryInterface = UserModel.sequelize.getQueryInterface();
80
+ const userTableName = UserModel.getTableName();
81
+ const userTableSchema = await queryInterface.describeTable(userTableName);
82
+ if (!userTableSchema.hashedRcExtensionId) {
83
+ logger.info('adding hashedRcExtensionId column to users table...');
84
+ await queryInterface.addColumn(userTableName, 'hashedRcExtensionId', {
85
+ type: Sequelize.STRING,
86
+ allowNull: true,
87
+ });
88
+ await UserModel.sync();
89
+ logger.info('hashedRcExtensionId column added to users table');
90
+ }
76
91
  }
77
92
  }
78
93
 
@@ -870,6 +885,7 @@ function createCoreRouter() {
870
885
  tokenUrl,
871
886
  query: req.query,
872
887
  proxyId: req.query.proxyId,
888
+ hashedRcExtensionId: hashedExtensionId,
873
889
  isFromMCP
874
890
  });
875
891
  if (userInfo) {
@@ -951,12 +967,7 @@ function createCoreRouter() {
951
967
  res.status(400).send(tracer ? tracer.wrapResponse('Missing platform name') : 'Missing platform name');
952
968
  return;
953
969
  }
954
- if (!apiKey) {
955
- tracer?.trace('apiKeyLogin:missingApiKey', {});
956
- res.status(400).send(tracer ? tracer.wrapResponse('Missing api key') : 'Missing api key');
957
- return;
958
- }
959
- const { userInfo, returnMessage } = await authCore.onApiKeyLogin({ platform, hostname, apiKey, proxyId, additionalInfo });
970
+ const { userInfo, returnMessage } = await authCore.onApiKeyLogin({ platform, hostname, apiKey, proxyId, rcAccountId: req.body.rcAccountId, hashedRcExtensionId: hashedExtensionId, additionalInfo });
960
971
  if (userInfo) {
961
972
  const jwtToken = jwt.generateJwt({
962
973
  id: userInfo.id.toString(),
@@ -1341,7 +1352,7 @@ function createCoreRouter() {
1341
1352
  if (extraDataTracking) {
1342
1353
  extraData = extraDataTracking;
1343
1354
  }
1344
- res.status(200).send(tracer ? tracer.wrapResponse({ successful, logId, returnMessage, pluginAsyncTaskIds }) : { successful, logId, returnMessage, pluginAsyncTaskIds });
1355
+ res.status(200).send(tracer ? tracer.wrapResponse({ successful, logId, returnMessage, pluginAsyncTaskIds }) : { successful, logId, returnMessage, pluginAsyncTaskIds });
1345
1356
  success = true;
1346
1357
  }
1347
1358
  }
package/mcp/README.md CHANGED
@@ -60,7 +60,7 @@ A stateless, hand-rolled JSON-RPC handler — no `@modelcontextprotocol/sdk`, no
60
60
  - Defines `inputSchema` (JSON Schema) for every tool that takes parameters — required so ChatGPT forwards arguments
61
61
  - Injects `rcAccessToken`, `openaiSessionId`, and `rcExtensionId` into every `tools/call` request
62
62
  - Verifies the RC access token against the RC API and caches `rcExtensionId` in `CacheModel` keyed by `openaiSessionId` (24h TTL) — subsequent requests hit the cache instead of the RC API
63
- - Automatically looks up and injects `jwtToken` from `LlmSessionModel` using `rcExtensionId` (or `openaiSessionId` as a fallback)
63
+ - Automatically looks up and injects `jwtToken` from `LlmSessionModel` using `rcExtensionId` (or `openaiSessionId` as a fallback), only when the linked `User` row still has a CRM `accessToken`
64
64
  - Stamps `WIDGET_URI` into `getPublicConnectors`'s `_meta['openai/outputTemplate']` at response time
65
65
  - Serves the widget HTML via `resources/read`
66
66
  - Exposes `handleWidgetToolCall` which searches both `tools.tools` and `tools.widgetTools`
@@ -116,7 +116,9 @@ Tools are split into two registries in `tools/index.js`:
116
116
  | `rcExtensionId` | RC API (`/extension/~`), verified once and cached in `sessionContext` | Cryptographically verified RC identity; used as `LlmSessionModel` key |
117
117
  | `jwtToken` | `LlmSessionModel.findByPk(rcExtensionId)` (fallback: `findByPk(openaiSessionId)`) | CRM auth token (after OAuth) |
118
118
 
119
- Tools do **not** need ChatGPT to pass `jwtToken` explicitly — it is resolved from the session automatically. The `rcExtensionId` is verified via the RC API on the **first tool call** of each session and cached for all subsequent calls within the same conversation (0 additional API calls after that).
119
+ Tools do **not** need ChatGPT to pass `jwtToken` explicitly — it is resolved from the session automatically. The `rcExtensionId` is verified via the RC API on the **first tool call** of each session and cached for all subsequent calls within the same conversation (0 additional API calls after that). If the user has logged out or the CRM token was cleared, `jwtToken` is not injected even when a stale row exists in `LlmSessionModel`.
120
+
121
+ New or refreshed LLM session JWTs are written only when the user record has an `accessToken`, so disconnected users are not issued a new tool JWT.
120
122
 
121
123
  Note: `widgetTools` are called via `POST /mcp/widget-tool-call` which bypasses the MCP session layer entirely. No server-side injection occurs for widget tool calls — all required values must be passed explicitly by the widget in the request body.
122
124
 
@@ -149,6 +151,7 @@ Logs out user from the CRM platform.
149
151
  | Destructive | Yes |
150
152
  | Parameters | `jwtToken` (optional — injected from session if not passed) |
151
153
  | Action | Clears user credentials |
154
+ | Note | Local session cleanup always runs. Failures from the connector `unAuthorize` call are logged; the tool still returns success so the client can clear context. |
152
155
 
153
156
  #### Contact & Call Log Tools
154
157
 
package/mcp/mcpHandler.js CHANGED
@@ -10,6 +10,9 @@ const axios = require('axios');
10
10
  const tools = require('./tools');
11
11
  const { LlmSessionModel } = require('../models/llmSessionModel');
12
12
  const { CacheModel } = require('../models/cacheModel');
13
+ const { UserModel } = require('../models/userModel');
14
+ const { getHashValue } = require('../lib/util');
15
+ const jwt = require('../lib/jwt');
13
16
  const logger = require('../lib/logger');
14
17
  const fs = require('fs');
15
18
  const path = require('path');
@@ -18,7 +21,7 @@ const path = require('path');
18
21
  * Increment this to bust ChatGPT's widget resource cache after every UI build.
19
22
  * This is the single source of truth — injected into getPublicConnectors _meta at response time.
20
23
  */
21
- const WIDGET_VERSION = 6;
24
+ const WIDGET_VERSION = 10;
22
25
  const WIDGET_URI = `ui://widget/ConnectorList-v${WIDGET_VERSION}.html`;
23
26
 
24
27
  const JSON_RPC_INTERNAL_ERROR = -32603;
@@ -206,8 +209,30 @@ async function handleMcpRequest(req, res) {
206
209
  await LlmSessionModel.upsert({ id: rcExtensionId, jwtToken: fallback.jwtToken });
207
210
  llmSession = fallback;
208
211
  }
212
+ else {
213
+ const hashedRcExtensionId = getHashValue(rcExtensionId, process.env.HASH_KEY);
214
+ const user = await UserModel.findOne({ where: { hashedRcExtensionId } });
215
+ if (user?.accessToken) {
216
+ await LlmSessionModel.upsert({
217
+ id: rcExtensionId,
218
+ jwtToken: jwt.generateJwt({
219
+ id: user.id.toString(),
220
+ platform: user.platform
221
+ }),
222
+ });
223
+ llmSession = await LlmSessionModel.findByPk(rcExtensionId);
224
+ }
225
+ }
226
+ }
227
+ if (llmSession?.jwtToken) {
228
+ const { id: userId } = jwt.decodeJwt(llmSession.jwtToken);
229
+ if (userId) {
230
+ const user = await UserModel.findByPk(userId);
231
+ if (user?.accessToken) {
232
+ toolArgs.jwtToken = llmSession.jwtToken;
233
+ }
234
+ }
209
235
  }
210
- if (llmSession?.jwtToken) toolArgs.jwtToken = llmSession.jwtToken;
211
236
  }
212
237
  }
213
238
 
@@ -9,7 +9,7 @@ const { LlmSessionModel } = require('../../models/llmSessionModel');
9
9
 
10
10
  const toolDefinition = {
11
11
  name: 'checkAuthStatus',
12
- description: 'Auth flow step.5. Check the status of an ongoing OAuth authentication session. Poll this after user clicks the auth link.',
12
+ description: 'Check the status of an ongoing OAuth authentication session. Poll this after user clicks the auth link.',
13
13
  inputSchema: {
14
14
  type: 'object',
15
15
  properties: {
@@ -1,4 +1,6 @@
1
1
  const axios = require('axios');
2
+ const { UserModel } = require('../../models/userModel');
3
+ const { getHashValue } = require('../../lib/util');
2
4
 
3
5
  /**
4
6
  * MCP Tool: Get Public Connectors
@@ -52,14 +54,30 @@ async function execute({ rcAccessToken, openaiSessionId } = {}) {
52
54
  }
53
55
  }
54
56
 
55
- return {
56
- structuredContent: {
57
- serverUrl: process.env.APP_SERVER || 'https://localhost:6066',
58
- rcExtensionId,
59
- rcAccountId,
60
- openaiSessionId: openaiSessionId ?? null,
57
+ // Check if user session already exists from Chrome extension
58
+ const hashedRcExtensionId = getHashValue(rcExtensionId, process.env.HASH_KEY);
59
+ const user = await UserModel.findOne({ where: { hashedRcExtensionId } });
60
+ // Case: user exists, return user info in plain message
61
+ if (user?.accessToken) {
62
+ return {
63
+ structuredContent: {
64
+ error: true,
65
+ errorMessage: `You are already connected to ${user.platform}. It's controlled from App Connect Chrome extension.`
66
+ }
61
67
  }
62
- };
68
+ }
69
+ else {
70
+ // Case: user doesn't exist, return structured content for widget
71
+ return {
72
+ structuredContent: {
73
+ serverUrl: process.env.APP_SERVER || 'https://localhost:6066',
74
+ rcExtensionId,
75
+ rcAccountId,
76
+ openaiSessionId: openaiSessionId ?? null,
77
+ }
78
+ };
79
+ }
80
+
63
81
  }
64
82
 
65
83
  exports.definition = toolDefinition;
@@ -1,5 +1,6 @@
1
1
  const jwt = require('../../lib/jwt');
2
2
  const { UserModel } = require('../../models/userModel');
3
+ const { LlmSessionModel } = require('../../models/llmSessionModel');
3
4
  const connectorRegistry = require('../../connector/registry');
4
5
 
5
6
  /**
@@ -22,6 +23,13 @@ const toolDefinition = {
22
23
  }
23
24
  };
24
25
 
26
+ function isMissingSessionTableError(error) {
27
+ const message = error?.message || '';
28
+ return message.includes('no such table: llmSessions')
29
+ || message.includes('relation "llmSessions" does not exist')
30
+ || message.includes("relation 'llmSessions' does not exist");
31
+ }
32
+
25
33
  /**
26
34
  * Execute the logout tool
27
35
  * @param {Object} args - The tool arguments
@@ -31,8 +39,19 @@ const toolDefinition = {
31
39
  async function execute(args) {
32
40
  try {
33
41
  const { jwtToken } = args;
34
- const { platform, id } = jwt.decodeJwt(jwtToken);
35
-
42
+ const session = jwt.decodeJwt(jwtToken);
43
+ if (!session?.platform || !session?.id) {
44
+ throw new Error('Invalid JWT token');
45
+ }
46
+ const { platform, id } = session;
47
+ try {
48
+ await LlmSessionModel.destroy({ where: { id } });
49
+ }
50
+ catch (error) {
51
+ if (!isMissingSessionTableError(error)) {
52
+ throw error;
53
+ }
54
+ }
36
55
  const userToLogout = await UserModel.findByPk(id);
37
56
  if (!userToLogout) {
38
57
  return {
@@ -42,7 +61,12 @@ async function execute(args) {
42
61
  };
43
62
  }
44
63
  const platformModule = connectorRegistry.getConnector(platform);
45
- await platformModule.unAuthorize({ user: userToLogout });
64
+ try {
65
+ await platformModule.unAuthorize({ user: userToLogout });
66
+ }
67
+ catch (error) {
68
+ console.log(error);
69
+ }
46
70
  return {
47
71
  success: true,
48
72
  data: {
@@ -60,4 +84,4 @@ async function execute(args) {
60
84
  }
61
85
 
62
86
  exports.definition = toolDefinition;
63
- exports.execute = execute;
87
+ exports.execute = execute;
@@ -6,7 +6,6 @@ import { AuthSuccess } from './components/AuthSuccess'
6
6
  import { setServerUrl } from './lib/callTool'
7
7
  import { fetchConnectors, fetchManifest } from './lib/developerPortal'
8
8
  import { dbg } from './lib/debugLog'
9
- import { DebugPanel } from './components/DebugPanel'
10
9
 
11
10
  // Initial structuredContent from getPublicConnectors — serverUrl, rcAccountId, rcExtensionId, openaiSessionId
12
11
  interface ToolOutput {
@@ -267,10 +266,9 @@ export function App() {
267
266
  if (data.error) {
268
267
  return (
269
268
  <div className="p-4">
270
- <div className="rounded-lg border border-red-200 bg-red-50 p-4">
271
- <p className="text-red-700">{data.errorMessage || 'An error occurred'}</p>
269
+ <div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4">
270
+ <p className="text-yellow-800">{data.errorMessage || 'An error occurred'}</p>
272
271
  </div>
273
- <DebugPanel />
274
272
  </div>
275
273
  )
276
274
  }
@@ -353,7 +351,6 @@ export function App() {
353
351
  >
354
352
  &larr; Back to connector list
355
353
  </button>
356
- <DebugPanel />
357
354
  </div>
358
355
  )}
359
356
  </div>