neoagent 2.2.1-beta.7 → 2.3.0

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.
@@ -239,18 +239,26 @@ function updateLastLogin(userId) {
239
239
  }
240
240
 
241
241
  router.get('/api/auth/status', (req, res) => {
242
+ const count = db.prepare('SELECT COUNT(*) as count FROM users').get();
243
+ const policy = getDeploymentPolicy();
244
+ const emailConfig = getEmailConfig();
245
+ const authProviderManager = getAuthProviderManager(req);
242
246
  const currentUser = readAuthenticatedUser(req);
243
247
  if (!currentUser) {
244
248
  return res.json({
249
+ hasUser: count.count > 0,
250
+ registrationOpen: policy.registrationOpen || count.count === 0,
251
+ deploymentProfile: policy.profile,
245
252
  authenticated: false,
246
253
  user: null,
254
+ email: {
255
+ enabled: emailConfig.enabled,
256
+ configured: emailConfig.configured,
257
+ signupConfirmationRequired: requireSignupEmailConfirmation(),
258
+ },
259
+ providers: authProviderManager ? authProviderManager.listProviders() : [],
247
260
  });
248
261
  }
249
-
250
- const count = db.prepare('SELECT COUNT(*) as count FROM users').get();
251
- const policy = getDeploymentPolicy();
252
- const emailConfig = getEmailConfig();
253
- const authProviderManager = getAuthProviderManager(req);
254
262
  res.json({
255
263
  hasUser: count.count > 0,
256
264
  registrationOpen: policy.registrationOpen || count.count === 0,
@@ -159,6 +159,7 @@ router.get('/:provider/connect/:sessionId', (req, res) => {
159
159
  if (!session) {
160
160
  return res.status(404).send('Connection session not found.');
161
161
  }
162
+ const trustedOrigin = JSON.stringify(getTrustedPostMessageOrigin(req));
162
163
  const statusUrl = `/api/integrations/${encodeURIComponent(req.params.provider)}/connect/${encodeURIComponent(req.params.sessionId)}/status?agentId=${encodeURIComponent(agentId)}`;
163
164
  res.send(`
164
165
  <html>
@@ -188,6 +189,14 @@ router.get('/:provider/connect/:sessionId', (req, res) => {
188
189
  const statusEl = document.getElementById('status');
189
190
  const qrEl = document.getElementById('qr');
190
191
  const statusUrl = ${JSON.stringify(statusUrl)};
192
+ const trustedOrigin = ${trustedOrigin};
193
+ const provider = ${JSON.stringify(req.params.provider)};
194
+ const appId = ${JSON.stringify(session.appKey)};
195
+
196
+ function notifyOpener(payload) {
197
+ if (!window.opener) return;
198
+ window.opener.postMessage(payload, trustedOrigin);
199
+ }
191
200
 
192
201
  async function refresh() {
193
202
  const response = await fetch(statusUrl, { credentials: 'same-origin' });
@@ -204,11 +213,24 @@ router.get('/:provider/connect/:sessionId', (req, res) => {
204
213
  } else if (status === 'connected') {
205
214
  qrEl.style.display = 'none';
206
215
  statusEl.textContent = 'Connected as ' + (data.accountEmail || 'your WhatsApp account') + '. Closing…';
216
+ notifyOpener({
217
+ type: 'integration_oauth_success',
218
+ provider,
219
+ appId,
220
+ connectionId: data.connectionId || null,
221
+ accountEmail: data.accountEmail || null,
222
+ });
207
223
  setTimeout(() => window.close(), 800);
208
224
  return;
209
225
  } else if (status === 'failed' || status === 'logged_out' || status === 'disconnected') {
210
226
  qrEl.style.display = 'none';
211
227
  statusEl.textContent = data.error || ('Connection ended with status: ' + status + '.');
228
+ notifyOpener({
229
+ type: 'integration_oauth_error',
230
+ provider,
231
+ appId,
232
+ error: data.error || ('Connection ended with status: ' + status + '.'),
233
+ });
212
234
  return;
213
235
  } else {
214
236
  statusEl.textContent = 'Waiting for WhatsApp to generate a QR code…';
@@ -4,6 +4,11 @@ const db = require('../db/database');
4
4
  const { requireAuth } = require('../middleware/auth');
5
5
  const { sanitizeError } = require('../utils/security');
6
6
  const { getAgentIdFromRequest, resolveAgentId } = require('../services/agents/manager');
7
+ const {
8
+ legacyWhitelistKey,
9
+ normalizeAccessPolicy,
10
+ summarizeAccessPolicy,
11
+ } = require('../services/messaging/access_policy');
7
12
 
8
13
  const PREFIXED_ENTRY_RE = /[^0-9a-z:_.@#+=\-!$*]/gi;
9
14
 
@@ -135,6 +140,37 @@ router.get('/status/:platform', (req, res) => {
135
140
  res.json(manager.getPlatformStatus(req.session.userId, req.params.platform, { agentId }));
136
141
  });
137
142
 
143
+ router.get('/:platform/access-policy', async (req, res) => {
144
+ try {
145
+ const manager = req.app.locals.messagingManager;
146
+ const platform = String(req.params.platform || '').replace(/[^0-9a-z_+-]/gi, '');
147
+ const agentId = resolveAgentId(req.session.userId, getAgentIdFromRequest(req));
148
+ if (!platform) return res.status(400).json({ error: 'platform is required' });
149
+ const catalog = await manager.getAccessCatalog(req.session.userId, platform, { agentId });
150
+ res.json(catalog);
151
+ } catch (err) {
152
+ res.status(500).json({ error: sanitizeError(err) });
153
+ }
154
+ });
155
+
156
+ router.put('/:platform/access-policy', (req, res) => {
157
+ try {
158
+ const manager = req.app.locals.messagingManager;
159
+ const platform = String(req.params.platform || '').replace(/[^0-9a-z_+-]/gi, '');
160
+ const agentId = resolveAgentId(req.session.userId, getAgentIdFromRequest(req));
161
+ if (!platform) return res.status(400).json({ error: 'platform is required' });
162
+ const policy = normalizeAccessPolicy(platform, req.body?.policy || req.body || {});
163
+ const saved = manager.setAccessPolicy(req.session.userId, platform, policy, { agentId });
164
+ res.json({
165
+ success: true,
166
+ policy: saved,
167
+ summary: summarizeAccessPolicy(platform, saved),
168
+ });
169
+ } catch (err) {
170
+ res.status(500).json({ error: sanitizeError(err) });
171
+ }
172
+ });
173
+
138
174
  router.get('/:platform/devices', (req, res) => {
139
175
  try {
140
176
  const manager = req.app.locals.messagingManager;
@@ -167,9 +203,9 @@ router.put('/telnyx/whitelist', (req, res) => {
167
203
  if (!Array.isArray(numbers)) return res.status(400).json({ error: 'numbers must be an array' });
168
204
  const list = numbers.map(n => n.replace(/[^0-9+]/g, '')).filter(Boolean);
169
205
  const agentId = resolveAgentId(req.session.userId, getAgentIdFromRequest(req));
170
- upsertAgentSetting(req.session.userId, agentId, 'platform_whitelist_telnyx', list);
206
+ upsertAgentSetting(req.session.userId, agentId, legacyWhitelistKey('telnyx'), list);
171
207
  const manager = req.app.locals.messagingManager;
172
- if (manager) manager.updateTelnyxAllowedNumbers(req.session.userId, list, { agentId });
208
+ const policy = manager ? manager.updateTelnyxAllowedNumbers(req.session.userId, list, { agentId }) : null;
173
209
  res.json({ success: true, numbers: list });
174
210
  } catch (err) {
175
211
  res.status(500).json({ error: sanitizeError(err) });
@@ -184,7 +220,7 @@ router.put('/discord/whitelist', (req, res) => {
184
220
  // Keep prefixed format, strip only clearly unsafe characters
185
221
  const list = ids.map(id => String(id).replace(/[^0-9a-z:_-]/gi, '')).filter(Boolean);
186
222
  const agentId = resolveAgentId(req.session.userId, getAgentIdFromRequest(req));
187
- upsertAgentSetting(req.session.userId, agentId, 'platform_whitelist_discord', list);
223
+ upsertAgentSetting(req.session.userId, agentId, legacyWhitelistKey('discord'), list);
188
224
  const manager = req.app.locals.messagingManager;
189
225
  if (manager) manager.updateDiscordAllowedIds(req.session.userId, list, { agentId });
190
226
  res.json({ success: true, ids: list });
@@ -201,7 +237,7 @@ router.put('/telegram/whitelist', (req, res) => {
201
237
  // Keep prefixed format; group IDs are negative so allow minus sign
202
238
  const list = ids.map(id => String(id).replace(/[^0-9a-z:_-]/gi, '')).filter(Boolean);
203
239
  const agentId = resolveAgentId(req.session.userId, getAgentIdFromRequest(req));
204
- upsertAgentSetting(req.session.userId, agentId, 'platform_whitelist_telegram', list);
240
+ upsertAgentSetting(req.session.userId, agentId, legacyWhitelistKey('telegram'), list);
205
241
  const manager = req.app.locals.messagingManager;
206
242
  if (manager) manager.updateTelegramAllowedIds(req.session.userId, list, { agentId });
207
243
  res.json({ success: true, ids: list });
@@ -219,7 +255,7 @@ router.put('/:platform/whitelist', (req, res) => {
219
255
  if (!platform) return res.status(400).json({ error: 'platform is required' });
220
256
  const list = ids.map(id => String(id).replace(PREFIXED_ENTRY_RE, '')).filter(Boolean);
221
257
  const agentId = resolveAgentId(req.session.userId, getAgentIdFromRequest(req));
222
- upsertAgentSetting(req.session.userId, agentId, `platform_whitelist_${platform}`, list);
258
+ upsertAgentSetting(req.session.userId, agentId, legacyWhitelistKey(platform), list);
223
259
  const manager = req.app.locals.messagingManager;
224
260
  if (manager?.updateAllowedEntries) manager.updateAllowedEntries(req.session.userId, platform, list, { agentId });
225
261
  res.json({ success: true, ids: list });
@@ -124,6 +124,7 @@ router.use(requireAuth);
124
124
  function isAgentScopedSettingKey(key) {
125
125
  return AGENT_SETTING_KEYS.has(key)
126
126
  || key.startsWith('platform_whitelist_')
127
+ || key.startsWith('platform_access_policy_')
127
128
  || key === 'platform_voice_secret_telnyx';
128
129
  }
129
130
 
@@ -144,6 +144,15 @@ function getAppScopes(appId) {
144
144
  return Array.from(new Set([...GOOGLE_ACCOUNT_IDENTITY_SCOPES, ...app.scopes]));
145
145
  }
146
146
 
147
+ function getAllWorkspaceScopes() {
148
+ return Array.from(
149
+ new Set([
150
+ ...GOOGLE_ACCOUNT_IDENTITY_SCOPES,
151
+ ...GOOGLE_WORKSPACE_APPS.flatMap((app) => app.scopes || []),
152
+ ]),
153
+ );
154
+ }
155
+
147
156
  function sortConnections(rows) {
148
157
  return rows.slice().sort((left, right) => {
149
158
  const leftEmail = String(left.account_email || '').toLowerCase();
@@ -381,7 +390,9 @@ function createGoogleWorkspaceProvider() {
381
390
  const url = client.generateAuthUrl({
382
391
  access_type: 'offline',
383
392
  prompt: 'consent',
384
- scope: getAppScopes(app.id),
393
+ // Request the full workspace scope bundle so one durable Google grant can
394
+ // back Gmail, Calendar, Drive, Docs, and Sheets for the same account.
395
+ scope: getAllWorkspaceScopes(),
385
396
  include_granted_scopes: true,
386
397
  state,
387
398
  code_challenge: codeChallenge,
@@ -410,7 +421,14 @@ function createGoogleWorkspaceProvider() {
410
421
  appId: app.id,
411
422
  accountEmail,
412
423
  credentials: client.credentials,
413
- scopes: getAppScopes(app.id),
424
+ scopes: Array.from(
425
+ new Set(
426
+ String(client.credentials.scope || '')
427
+ .split(/\s+/)
428
+ .map((scope) => scope.trim())
429
+ .filter(Boolean),
430
+ ),
431
+ ),
414
432
  metadata: {
415
433
  appId: app.id,
416
434
  },
@@ -53,6 +53,58 @@ class IntegrationManager {
53
53
  return merged;
54
54
  }
55
55
 
56
+ findReusableConnection(userId, agentId, providerKey, accountEmail, options = {}) {
57
+ const normalizedEmail = String(accountEmail || '').trim().toLowerCase();
58
+ if (!normalizedEmail) return null;
59
+ const excludedConnectionId = Number(options.excludeConnectionId);
60
+ const connections = this.listConnections(userId, providerKey, agentId).filter(
61
+ (connection) =>
62
+ String(connection.account_email || '').trim().toLowerCase() === normalizedEmail &&
63
+ connection.status === 'connected' &&
64
+ (!Number.isInteger(excludedConnectionId) || connection.id !== excludedConnectionId),
65
+ );
66
+ if (connections.length === 0) return null;
67
+ return connections.sort((left, right) =>
68
+ String(right.updated_at || '').localeCompare(String(left.updated_at || '')),
69
+ )[0];
70
+ }
71
+
72
+ mergeWithReusableCredentials(userId, agentId, providerKey, accountEmail, credentials, options = {}) {
73
+ const reusableConnection = this.findReusableConnection(
74
+ userId,
75
+ agentId,
76
+ providerKey,
77
+ accountEmail,
78
+ options,
79
+ );
80
+ if (!reusableConnection) {
81
+ return credentials && typeof credentials === 'object' ? credentials : {};
82
+ }
83
+ const reusableCredentials = this.parseCredentials(
84
+ reusableConnection.credentials_json,
85
+ );
86
+ return this.mergeUpdatedCredentials(
87
+ reusableCredentials,
88
+ credentials,
89
+ );
90
+ }
91
+
92
+ persistSharedCredentials(userId, agentId, providerKey, accountEmail, credentials) {
93
+ const normalizedEmail = String(accountEmail || '').trim();
94
+ if (!normalizedEmail) return;
95
+ db.prepare(
96
+ `UPDATE integration_connections
97
+ SET credentials_json = ?, updated_at = datetime('now')
98
+ WHERE user_id = ? AND agent_id = ? AND provider_key = ? AND lower(account_email) = lower(?)`,
99
+ ).run(
100
+ encryptValue(JSON.stringify(credentials || {})),
101
+ userId,
102
+ agentId,
103
+ providerKey,
104
+ normalizedEmail,
105
+ );
106
+ }
107
+
56
108
  getProvider(providerKey) {
57
109
  return this.registry.get(providerKey);
58
110
  }
@@ -218,6 +270,19 @@ class IntegrationManager {
218
270
  appKey: stateRow.app_key,
219
271
  });
220
272
 
273
+ const mergedCredentials = this.mergeWithReusableCredentials(
274
+ stateRow.user_id,
275
+ stateRow.agent_id || resolveAgentId(stateRow.user_id, null),
276
+ provider.key,
277
+ result.accountEmail,
278
+ result.credentials,
279
+ );
280
+ if (!mergedCredentials.refresh_token) {
281
+ throw new Error(
282
+ `${provider.label} did not return a refresh token, so the connection would expire. Revoke the existing app grant for this provider and reconnect it so offline access is granted.`,
283
+ );
284
+ }
285
+
221
286
  db.prepare(
222
287
  `INSERT INTO integration_connections (
223
288
  user_id,
@@ -246,10 +311,18 @@ class IntegrationManager {
246
311
  stateRow.app_key,
247
312
  result.accountEmail,
248
313
  JSON.stringify(result.scopes || []),
249
- encryptValue(JSON.stringify(result.credentials || {})),
314
+ encryptValue(JSON.stringify(mergedCredentials)),
250
315
  JSON.stringify(result.metadata || {}),
251
316
  );
252
317
 
318
+ this.persistSharedCredentials(
319
+ stateRow.user_id,
320
+ stateRow.agent_id || resolveAgentId(stateRow.user_id, null),
321
+ provider.key,
322
+ result.accountEmail,
323
+ mergedCredentials,
324
+ );
325
+
253
326
  const connection = db
254
327
  .prepare(
255
328
  `SELECT * FROM integration_connections
@@ -548,14 +621,12 @@ class IntegrationManager {
548
621
  existingCredentials,
549
622
  execution.credentials,
550
623
  );
551
- db.prepare(
552
- `UPDATE integration_connections
553
- SET credentials_json = ?, updated_at = datetime('now')
554
- WHERE id = ? AND user_id = ?`,
555
- ).run(
556
- encryptValue(JSON.stringify(mergedCredentials)),
557
- selection.connection.id,
624
+ this.persistSharedCredentials(
558
625
  userId,
626
+ resolveAgentId(userId, agentId),
627
+ provider.key,
628
+ selection.connection.account_email,
629
+ mergedCredentials,
559
630
  );
560
631
  }
561
632